This commit is contained in:
Marsway 2026-04-30 15:30:32 +08:00
parent d12cc71bd4
commit e60f568746
2 changed files with 115 additions and 3 deletions

View File

@ -0,0 +1,96 @@
# sync_ehr_to_ad 使用说明
该 extension 从北森 EHR 拉取当前用户信息,并同步到本地 AD 域中已存在的用户对象。
## Job
- `handler_path`: `extensions.sync_ehr_to_ad.job:SyncEhrToAdUserJob`
- `job_id`: `sync_ehr_to_ad.sync_users`
同步行为:
- 只处理当前在职用户。
- 只更新 AD 中已存在的 `sAMAccountName` 用户,不自动创建 AD 账号。
- 更新前会比对 AD 当前值与 EHR 目标值;一致则跳过,不一致才更新差异字段。
- EHR 目标值为空时不会清空 AD 字段。
- 如果配置了 `target_sam_accounts`,只对列表中的 AD 账号执行同步。
## public_cfg 示例
```json
{
"ldap_uri": "ldaps://dc01.vastai.com:636",
"ldap_base_dn": "DC=vastai,DC=com",
"ldap_user_filter": "(sAMAccountName={sAMAccountName})",
"ldap_verify_tls": false,
"dry_run": true,
"target_sam_accounts": ["fchen", "jqian"],
"proxy_alias_domain": "vastaitech.com",
"default_company": "Vastai Technologies",
"department_code_ad_attribute": "departmentNumber",
"postal_code": "201210",
"location_mappings": {
"上海": {
"co": "China",
"c": "CN",
"countryCode": 156,
"st": "Shanghai",
"l": "Shanghai"
}
}
}
```
## secret_cfg 示例
```json
{
"app_key": "EHR_APP_KEY",
"app_secret": "EHR_APP_SECRET",
"ldap_bind_dn": "CN=svc-ehr-ad,OU=Service Accounts,DC=vastai,DC=com",
"ldap_bind_password": "password"
}
```
## 常用 public_cfg
- `target_sam_accounts`: 可选,字符串数组。传入后只同步这些 AD 账号;不传则同步所有当前用户。
- `dry_run`: 可选,默认 `false`。建议首次配置为 `true`,只记录会变更的字段,不写入 AD。
- `ldap_uri`: AD LDAP 地址,如 `ldaps://dc01.vastai.com:636`
- `ldap_base_dn`: AD 用户搜索根 DN。
- `ldap_user_filter`: 用户搜索过滤器,默认 `(sAMAccountName={sAMAccountName})`
- `ldap_verify_tls`: 是否校验证书,默认 `true`
- `proxy_alias_domain`: 生成 `smtp:<sAMAccountName>@domain` 别名时使用的域名。
- `department_code_ad_attribute`: 部门编码写入的 AD 属性,默认 `departmentNumber`
- `default_company`: 固定公司名;不传时尝试取 EHR 根组织名称。
- `location_mappings`: 工作地点到 AD 国家、省、市字段的映射。
## 指定用户同步示例
只验证并同步 `fchen` 一个用户:
```json
{
"ldap_uri": "ldaps://dc01.vastai.com:636",
"ldap_base_dn": "DC=vastai,DC=com",
"ldap_verify_tls": false,
"dry_run": true,
"target_sam_accounts": ["fchen"],
"proxy_alias_domain": "vastaitech.com",
"default_company": "Vastai Technologies"
}
```
确认日志中的 `changed_attrs` 符合预期后,将 `dry_run` 改为 `false` 即可真实写入。
## 返回统计
Job 返回结果包含:
- `filtered_by_target_sam`: 是否启用了指定用户过滤。
- `target_sam_accounts`: 指定用户数量。
- `processed`: 实际处理的用户数。
- `updated`: 有差异并执行更新的用户数。
- `skipped_unchanged`: AD 与 EHR 一致而跳过的用户数。
- `skipped_not_found_ad`: EHR 有 AD 账号但 AD 中找不到的用户数。
- `failed`: 同步失败的用户数。

View File

@ -310,6 +310,15 @@ def _job_post_name(job_post: dict[str, Any]) -> str:
return "" return ""
def _parse_target_sam_accounts(params: dict[str, Any]) -> set[str]:
raw = params.get("target_sam_accounts")
if raw is None:
return set()
if not isinstance(raw, list):
raise ValueError("public_cfg.target_sam_accounts must be a JSON array, e.g. [\"fchen\", \"jqian\"]")
return {str(x).strip().lower() for x in raw if str(x).strip()}
class SyncEhrToAdUserJob(BaseJob): class SyncEhrToAdUserJob(BaseJob):
""" """
EHR 当前人员 -> 本地 AD 用户属性同步 EHR 当前人员 -> 本地 AD 用户属性同步
@ -360,6 +369,7 @@ class SyncEhrToAdUserJob(BaseJob):
department_code_attr = str(params.get("department_code_ad_attribute") or "departmentNumber").strip() department_code_attr = str(params.get("department_code_ad_attribute") or "departmentNumber").strip()
postal_code = str(params.get("postal_code") or "").strip() postal_code = str(params.get("postal_code") or "").strip()
default_company = str(params.get("default_company") or "").strip() default_company = str(params.get("default_company") or "").strip()
target_sam_accounts = _parse_target_sam_accounts(params)
current_status_values_param = params.get("current_status_values") current_status_values_param = params.get("current_status_values")
if isinstance(current_status_values_param, list): if isinstance(current_status_values_param, list):
@ -435,20 +445,24 @@ class SyncEhrToAdUserJob(BaseJob):
sam = _field_value(item, domain_account_key) sam = _field_value(item, domain_account_key)
if not sam: if not sam:
continue continue
existing = users_by_sam.get(sam.lower()) sam_key = sam.lower()
users_by_sam[sam.lower()] = item if existing is None else _choose_better_record(existing, item)
user_id = _to_int_safe((item.get("employeeInfo") or {}).get("userID")) user_id = _to_int_safe((item.get("employeeInfo") or {}).get("userID"))
if user_id > 0: if user_id > 0:
user_id_to_sam[user_id] = sam user_id_to_sam[user_id] = sam
if target_sam_accounts and sam_key not in target_sam_accounts:
continue
existing = users_by_sam.get(sam_key)
users_by_sam[sam_key] = item if existing is None else _choose_better_record(existing, item)
if max_users > 0 and len(users_by_sam) >= max_users: if max_users > 0 and len(users_by_sam) >= max_users:
break break
logger.info( logger.info(
"EHR 当前用户准备完成employee_rows=%s current_with_ad_account=%s org_rows=%s job_post_rows=%s", "EHR 当前用户准备完成employee_rows=%s current_with_ad_account=%s org_rows=%s job_post_rows=%s target_sam_accounts=%s",
len(emp_rows), len(emp_rows),
len(users_by_sam), len(users_by_sam),
len(org_rows), len(org_rows),
len(job_post_rows), len(job_post_rows),
len(target_sam_accounts),
) )
processed = 0 processed = 0
@ -587,6 +601,8 @@ class SyncEhrToAdUserJob(BaseJob):
result = { result = {
"ok": failed == 0, "ok": failed == 0,
"dry_run": dry_run, "dry_run": dry_run,
"filtered_by_target_sam": bool(target_sam_accounts),
"target_sam_accounts": len(target_sam_accounts),
"ehr_employee_rows": len(emp_rows), "ehr_employee_rows": len(emp_rows),
"ehr_current_users_with_ad_account": len(users_by_sam), "ehr_current_users_with_ad_account": len(users_by_sam),
"processed": processed, "processed": processed,