From e60f5687462fbf936c071e2b1b7d3eb5685c7b86 Mon Sep 17 00:00:00 2001 From: Marsway Date: Thu, 30 Apr 2026 15:30:32 +0800 Subject: [PATCH] update --- extensions/sync_ehr_to_ad/README.md | 96 +++++++++++++++++++++++++++++ extensions/sync_ehr_to_ad/job.py | 22 ++++++- 2 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 extensions/sync_ehr_to_ad/README.md diff --git a/extensions/sync_ehr_to_ad/README.md b/extensions/sync_ehr_to_ad/README.md new file mode 100644 index 0000000..3beb786 --- /dev/null +++ b/extensions/sync_ehr_to_ad/README.md @@ -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:@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`: 同步失败的用户数。 diff --git a/extensions/sync_ehr_to_ad/job.py b/extensions/sync_ehr_to_ad/job.py index fd93a18..dec2514 100644 --- a/extensions/sync_ehr_to_ad/job.py +++ b/extensions/sync_ehr_to_ad/job.py @@ -310,6 +310,15 @@ def _job_post_name(job_post: dict[str, Any]) -> str: 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): """ EHR 当前人员 -> 本地 AD 用户属性同步。 @@ -360,6 +369,7 @@ class SyncEhrToAdUserJob(BaseJob): department_code_attr = str(params.get("department_code_ad_attribute") or "departmentNumber").strip() postal_code = str(params.get("postal_code") 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") if isinstance(current_status_values_param, list): @@ -435,20 +445,24 @@ class SyncEhrToAdUserJob(BaseJob): sam = _field_value(item, domain_account_key) if not sam: continue - existing = users_by_sam.get(sam.lower()) - users_by_sam[sam.lower()] = item if existing is None else _choose_better_record(existing, item) + sam_key = sam.lower() user_id = _to_int_safe((item.get("employeeInfo") or {}).get("userID")) if user_id > 0: 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: break 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(users_by_sam), len(org_rows), len(job_post_rows), + len(target_sam_accounts), ) processed = 0 @@ -587,6 +601,8 @@ class SyncEhrToAdUserJob(BaseJob): result = { "ok": failed == 0, "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_current_users_with_ad_account": len(users_by_sam), "processed": processed,