From 2526052e73aa59b9ece610a6886163659137c8ac Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 27 Jul 2026 13:05:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BC=81=E5=BE=AE=E9=80=9A?= =?UTF-8?q?=E8=AE=AF=E5=BD=95=E7=BC=BA=E5=A4=B1=E6=89=8B=E6=9C=BA=E5=8F=B7?= =?UTF-8?q?=EF=BC=8807-26-internal-ops-recovery-baseline=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/hyb-oa/wecom-boundary.md | 101 +++++- .../design.md | 9 + .../implement.md | 11 + .../prd.md | 16 +- client/src/api/employees.ts | 4 +- client/src/api/types.ts | 2 +- client/src/api/users.ts | 4 +- client/src/pages/EmployeeProfilePage.tsx | 2 +- client/src/pages/UserManagementPage.tsx | 2 +- .../user-management/CreateUserDialog.tsx | 9 +- .../user-management/UserImportDialog.tsx | 6 +- docs/企微API能力目录.md | 3 +- server/package.json | 1 + .../migration.sql | 5 + server/prisma/schema.prisma | 2 +- server/src/routes/users.js | 33 +- .../scripts/import-wecom-directory-export.js | 296 ++++++++++++++++++ .../services/integrations/wecomHrMaster.js | 34 +- .../integrations/wecomOAuthOnboarding.js | 15 +- server/tests/unit/routes/users.import.test.js | 27 ++ server/tests/unit/routes/users.write.test.js | 65 +++- .../import-wecom-directory-export.test.js | 169 ++++++++++ .../tests/unit/wecomHrMaster.service.test.js | 32 ++ .../unit/wecomOAuthOnboarding.service.test.js | 60 ++++ 24 files changed, 845 insertions(+), 63 deletions(-) create mode 100644 server/prisma/migrations/20260727153000_allow_employee_phone_null/migration.sql create mode 100644 server/src/scripts/import-wecom-directory-export.js create mode 100644 server/tests/unit/scripts/import-wecom-directory-export.test.js diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 74fb52f2..3fa012db 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -168,9 +168,10 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history - `login|bind` always keeps `snsapi_base`; only explicit `onboard` uses `snsapi_privateinfo`. - The member must be visible to the app, active, return the same userid from - both identity calls, and authorize a valid phone plus personal email or - business email. A blank/invalid personal email may fall back to a valid - business email. + both identity calls, and authorize a valid personal email or business email. + Phone may be absent; when returned it must be normalized and checked for + uniqueness. A blank/invalid personal email may fall back to a valid business + email. - `user_ticket`, OAuth code, access token and raw provider responses exist only in request memory. Do not persist them or include them in Cookie, audit, response details or logs; provider/network failures use fixed safe codes. @@ -190,8 +191,9 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history - Provider/network failure -> `wecom_api_failed` or safe auth error; no raw payload, token or ticket in output/logs. - Userid mismatch -> `identity_mismatch`; inactive member -> `member_inactive`. -- Missing/invalid phone, both emails invalid, or unresolved department -> - `missing_required_field`. +- Both emails invalid or unresolved department -> `missing_required_field`. +- Phone absent -> continue with `NULL`; phone present but conflicting -> + `employee_conflict`. - Multiple employees, unique-field race or cross-binding -> `employee_conflict`. - Department binding points to missing/archived data -> `department_invalid`; multiple same-name OA departments -> `department_ambiguous`. @@ -232,3 +234,92 @@ ordinary login -> snsapi_base one-time onboard -> explicit private consent -> safe unique employee-only write business role assignment -> separate administrator-audited operation ``` + +## Scenario: WeCom Directory Export With Optional Phone + +### 1. Scope / Trigger + +- Trigger: importing an administrator-exported WeCom directory XLSX, or + changing `Employee.phone` across database, API, HR sync, OAuth and UI layers. +- Purpose: allow a real member to remain temporarily without a phone while + preserving stable identity, uniqueness and privacy controls. + +### 2. Signatures + +- DB: `Employee.phone String? @unique`; migration drops only the `NOT NULL` + constraint and keeps the unique index. +- CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`. +- Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply + --actor-employee-no=`. +- User API and frontend employee/user types expose `phone: string | null`; + create/update accepts an omitted or explicit `null` phone. + +### 3. Contracts + +- Missing phone is stored as SQL `NULL`, never `""`, a repeated placeholder or + a synthesized fake number. Multiple `NULL` rows are valid; non-null phones + remain unique. +- WeCom account and valid enterprise email remain required for directory-export + onboarding. Account, non-null phone and enterprise email duplicates block + the input. +- An empty source phone never clears an existing OA phone. Only an explicitly + supplied valid phone participates in update and unique-conflict checks. +- The XLSX is accepted only from stdin and defaults to dry-run. Apply requires + both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor. +- CLI output contains counts and categories only. The workbook, full local path, + names, accounts, phones, emails, departments and raw rows must not enter Git, + Trellis artifacts or reports. +- New accounts receive only `employee`; finance, CEO and admin roles remain a + separate administrator-audited operation. + +### 4. Validation & Error Matrix + +- Missing phone cell -> valid profile with `phone=null`. +- Supplied malformed phone -> incomplete/blocked; do not downgrade it to an + absent phone and do not write it. +- Missing/invalid enterprise email, account, name or department -> skipped or + blocked as `missing_required_field`. +- Duplicate account/non-null phone/enterprise email in the workbook -> + `duplicate_values`; error output contains counts only. +- Existing OA unique-field or binding conflict -> conflict; no automatic merge. +- `--apply` without confirmation env or active actor -> refuse before sync. +- Migration rollback with any `phone IS NULL` row -> stop; do not fill fake + values merely to restore `NOT NULL`. + +### 5. Good/Base/Bad Cases + +- Good: five members have unique phones, one member has no phone, all six have + unique accounts and enterprise emails; dry-run plans six records and reports + only aggregate counts. +- Base: a later export still omits a member phone; the member's existing OA + phone is preserved. +- Bad: write an empty string or shared placeholder, use personal email in place + of the required enterprise email for bulk identity, or auto-grant a business + role during import. + +### 6. Tests Required + +- Prisma validation plus a migration review proving only `DROP NOT NULL`. +- Unit tests for instruction rows/Chinese headers, five-present/one-missing, + malformed phone, missing enterprise email and duplicate account/phone/email. +- Route/service tests for create/update/import `null`, non-empty unique checks, + OAuth missing phone and empty-source preservation of an existing phone. +- Frontend typecheck/build and displays that render a clear empty-phone label. +- Full backend regression, Trellis governance, sensitive-content scan and + `git diff --check`; a real workbook may be parsed only into aggregate counts + and remains Git-ignored. + +### 7. Wrong vs Correct + +#### Wrong + +```js +phone: source.phone || "00000000000" +``` + +#### Correct + +```js +phone: source.phone || null +// Existing employee updates omit `phone` entirely when the source is empty. +``` diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md index 508b8424..55b0f010 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -108,6 +108,15 @@ GET /auth/wecom/login?mode=onboard - 已有绑定员工只补齐经授权的规范化资料且不改角色;新员工在单事务内创建 employee、`employee` 角色、企微绑定和脱敏审计。任何一步失败均不留下部分账号。 - 自助开通不替代管理员职责分配;`finance`、`ceo` 仍沿用角色接口的 token version、会话撤销和审计合同。 +## 企微通讯录导出与手机号可空 + +- 管理员导出的 XLSX 只作为一次性受控输入,不进入 Git、Trellis、报告或生产文件系统;CLI 支持从 stdin 读取,默认仅 dry-run。 +- 入口识别企微官方说明行和中文表头,将姓名、账号、职务、部门、手机、邮箱/企业邮箱、激活/禁用状态归一化为既有 HR master profile,再复用部门解析、员工匹配、企微绑定、审计和 `employee` 初始角色语义。 +- `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。 +- 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。 +- apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。 +- 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。 + ## 部署目录统一设计约束 - 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md index 80a73b7a..91dfb674 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -29,6 +29,9 @@ - [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 - [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 - [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 +- [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。 +- [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。 +- [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。 ## 推荐文件边界 @@ -52,6 +55,7 @@ - `cd server && npm run prisma:validate` - `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js` - `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js` +- `cd server && npm test -- --run tests/unit/routes/users.import.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js tests/unit/scripts/import-wecom-directory-export.test.js` - `bash -n deploy/trialrun/deploy.sh` - `bash -n deploy/trialrun/install-warch3-ops-timer.sh` @@ -71,6 +75,13 @@ - 已从活动 DB 容器与活动 server 附件挂载生成正确批次并复制到 ARM 备份机;DB catalog、manifest、源/目标产物 SHA-256、附件 tar 与 0 个普通文件计数均通过,且未伪造旧实例 build SHA。ARM 只承担异机备份与恢复演练职责,不运行 OA 服务。 - 已在 ARM 备份机用不发布端口的一次性 PostgreSQL 16 容器恢复正确批次:58 项成功迁移、12 张关键表计数和 0 个附件普通文件均与冻结基线一致,包含镜像拉取的实测 RTO 为 33 秒。演练后容器、临时附件目录和本轮新增镜像均已删除,未运行 OA server;应用 `/ready` 留待生产部署环境验证。 +## 手机号可空与通讯录导入本地证据(2026-07-27) + +- 后端全量 Vitest:163 个测试文件、1016 个测试通过;补充双 apply 门断言后,通讯录导入聚焦 8 项再次通过。新增覆盖普通用户创建/显式清空、CSV 空手机号、HR 空来源保留既有值、OAuth 无手机号建档、通讯录说明行/中文表头、企业邮箱门禁、非法手机号、重复字段、apply 双门及脱敏状态。 +- `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。 +- 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。 +- 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。 + ## 生产部署与 timer 收口证据(2026-07-27) - 已在获批停机窗口把生产切换到唯一 Compose project;活动 DB 与附件从停机冻结副本进入新的独立目录,旧容器保持停止,旧数据目录原位保留作回滚点。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md index 7870d6e7..b027fe9e 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -22,6 +22,7 @@ - 经用户逐项授权后,修正企微通讯录与人事助手的双 Secret 读取分工,并用脱敏 dry-run 唯一确认两名既有企微成员。 - 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。 - 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。 +- 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。 - 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。 ## Requirements @@ -37,10 +38,11 @@ - 将真实内部实例核查列为后续同任务外部门禁:运行 commit/访问方式/TLS、真实职责分配、异机副本和恢复后 `/ready` 均需另行批准和脱敏留证。 - 企微通讯录 Secret 只用于 `/user/list_id`;自建应用 Secret 用于 `/user/get`、`/department/list` 和获授权的人事助手 `/hr/get_staff_info`,不得回退到已废弃的人事助手 Secret。 - 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。 +- `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。 - 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。 -- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并实际同意手机号及邮箱或企业邮箱授权。 +- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。 - `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。 -- 自助开通按企微 userid、手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 +- 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 - 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 - 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 - Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。 @@ -66,14 +68,15 @@ - [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。 - [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。 - [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 +- [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。 ## Out of Scope - 不读取真实附件正文,不执行生产恢复、数据库 reset、迁移历史改写或付款/报销业务写入。 -- 不修改 Prisma schema、公开业务 API、权限模型、审批链或付款状态机;仅修复既有 HR 同步服务的凭证分工,并通过既有角色模型写入获批职责。 +- 除已批准的 `Employee.phone` nullable 兼容外,不扩大修改其他 Prisma schema、权限模型、审批链或付款状态机;公开用户 API 仅同步手机号可空合同,并通过既有角色模型写入获批职责。 - 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。 - 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。 -- 不发送企微消息、不创建企微审批模板、不把手机号/邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。 +- 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。 - 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。 ## Test Plan @@ -97,13 +100,16 @@ 先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。 -企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料,不修改全局员工字段可空性;自助开通只授予 `employee`,业务角色继续由管理员审计写入。 +企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料;手机号按已批准范围改为全局 nullable,空值统一保存为 `NULL`。自助开通只授予 `employee`,业务角色继续由管理员审计写入。 ## Local Evidence - 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。 - 演练覆盖 manifest/校验和、错误确认、正式风格库名、损坏 DB、损坏 tar、非空附件目录、默认试运行附件目录和危险 tar 路径拒绝。 - 该证据不包含真实业务数据、真实运行实例、异机副本或部署后 `/ready`,因此最后一项验收保持未完成。 +- 2026-07-27 完成手机号 nullable 本地切片:Prisma 仅新增 `phone DROP NOT NULL` migration,用户创建/更新/批量导入、HR master、本人授权和前端空值合同已同步;本轮没有执行 migration 或写入真实员工。 +- 合成通讯录覆盖说明行/中文表头、5 个手机号加 1 个缺失、非法手机号、缺企业邮箱、重复账号/非空手机号/企业邮箱、双重 apply 门和脱敏报告。管理员提供的真实 XLSX 仅在内存中只读解析为 6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库,文件保持 Git 排除。 +- 最终本地门禁:后端全量 163 个测试文件、1016 个测试通过;补充双 apply 门测试后,通讯录导入聚焦 8 项再次通过。Prisma validate、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。文档审计保留仓库既有 22 个未注册文件 warning,无新增 error。 ## Live Read-Only Evidence diff --git a/client/src/api/employees.ts b/client/src/api/employees.ts index f74921cc..e9eb489c 100644 --- a/client/src/api/employees.ts +++ b/client/src/api/employees.ts @@ -91,7 +91,7 @@ export type EmployeeSummary = { id: string; employeeNo: string; name: string; - phone: string; + phone: string | null; email: string; status: EmployeeStatus; departmentId: string; @@ -231,7 +231,7 @@ export type EmployeeProfile = { id: string; employeeNo: string; name: string; - phone: string; + phone: string | null; email: string; status: EmployeeStatus; employmentStatus: EmployeeEmploymentStatus; diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 99593375..5d003049 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -35,7 +35,7 @@ export type MeResponse = { id: string; employeeNo: string; name: string; - phone: string; + phone: string | null; email: string; departmentId: string; directManagerId: string | null; diff --git a/client/src/api/users.ts b/client/src/api/users.ts index d3b390f1..53a713be 100644 --- a/client/src/api/users.ts +++ b/client/src/api/users.ts @@ -6,7 +6,7 @@ export type UserSummary = { id: string; employeeNo: string; name: string; - phone: string; + phone: string | null; email: string; status: UserStatus; departmentId: string; @@ -89,7 +89,7 @@ export async function fetchUsers(params: { export async function createUser(payload: { name: string; - phone: string; + phone: string | null; email: string; departmentId?: string; directManagerId?: string | null; diff --git a/client/src/pages/EmployeeProfilePage.tsx b/client/src/pages/EmployeeProfilePage.tsx index 4a1265b9..ca1fa2be 100644 --- a/client/src/pages/EmployeeProfilePage.tsx +++ b/client/src/pages/EmployeeProfilePage.tsx @@ -802,7 +802,7 @@ export function EmployeeProfilePage() {
手机 / 邮箱
- {profile.employee.phone} / {profile.employee.email} + {profile.employee.phone ?? "未填写"} / {profile.employee.email}
diff --git a/client/src/pages/UserManagementPage.tsx b/client/src/pages/UserManagementPage.tsx index 633e6569..9c8c1766 100644 --- a/client/src/pages/UserManagementPage.tsx +++ b/client/src/pages/UserManagementPage.tsx @@ -322,7 +322,7 @@ export function UserManagementPage() {
{item.email}
-
{item.phone}
+
{item.phone ?? "手机号未填写"}
{formatRoleCodes(item.roleCodes)} diff --git a/client/src/pages/user-management/CreateUserDialog.tsx b/client/src/pages/user-management/CreateUserDialog.tsx index 8fa152dc..34696bf3 100644 --- a/client/src/pages/user-management/CreateUserDialog.tsx +++ b/client/src/pages/user-management/CreateUserDialog.tsx @@ -13,7 +13,7 @@ import { Label } from "../../components/ui/label"; export interface CreateUserFormData { name: string; - phone: string; + phone: string | null; email: string; departmentId: string | undefined; directManagerId: string | null; @@ -61,7 +61,6 @@ export function CreateUserDialog({ const canSubmit = !isPending && trimmedName !== "" && - trimmedPhone !== "" && trimmedEmail !== "" && rolesArray.length >= 1; @@ -84,7 +83,7 @@ export function CreateUserDialog({ function handleSubmit() { onSubmit({ name: trimmedName, - phone: trimmedPhone, + phone: trimmedPhone || null, email: trimmedEmail, departmentId: departmentId || undefined, directManagerId: directManagerId ? directManagerId : null, @@ -117,13 +116,13 @@ export function CreateUserDialog({ />
- + setPhone(e.target.value)} - placeholder="13800000001" + placeholder="后续可由本人授权或管理员补齐" />
diff --git a/client/src/pages/user-management/UserImportDialog.tsx b/client/src/pages/user-management/UserImportDialog.tsx index ce84866b..992e6794 100644 --- a/client/src/pages/user-management/UserImportDialog.tsx +++ b/client/src/pages/user-management/UserImportDialog.tsx @@ -31,7 +31,7 @@ export interface UserImportDialogProps { const TEMPLATE_CSV = [ "employeeNo,name,phone,email,departmentName,title,directManagerEmployeeNo,roles,hireDate", "E1001,李经理,13800000001,manager@example.com,行政部,经理,,manager,2026-03-01", - "E1002,张三,13800000002,zhangsan@example.com,行政部,员工,E1001,employee,2026-03-15", + "E1002,张三,,zhangsan@example.com,行政部,员工,E1001,employee,2026-03-15", ].join("\n"); function toCsvCell(value: string) { @@ -205,10 +205,10 @@ export function UserImportDialog({ open, onOpenChange, onImported }: UserImportD }} />
- 必填列:employeeNo、name、phone、email、departmentName + 必填列:employeeNo、name、email、departmentName
- 选填列:title、directManagerEmployeeNo、roles、hireDate + 选填列:phone、title、directManagerEmployeeNo、roles、hireDate
{selectedFile ? (
diff --git a/docs/企微API能力目录.md b/docs/企微API能力目录.md index 26938d4b..5444dd42 100644 --- a/docs/企微API能力目录.md +++ b/docs/企微API能力目录.md @@ -40,9 +40,10 @@ | 企业微信回调 IP | [获取企业微信回调 IP 段](https://developer.work.weixin.qq.com/document/path/92521) | `/cgi-bin/getcallbackip` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 用于回调入口放行和安全审计,不进入业务数据。 | | 网页授权链接 | [构造网页授权链接](https://developer.work.weixin.qq.com/document/path/91022) | `connect/oauth2/authorize` | 浏览器跳转 | `WECOM_AGENT_SECRET` 所属应用 | `CAN_REUSE_NOW` | 企业微信工作台入口和 deeplink 回 OA 使用;只改变登录体验,不改变 OA 权限。 | | 访问用户身份 | [获取访问用户身份](https://developer.work.weixin.qq.com/document/path/91023) | `/cgi-bin/auth/getuserinfo` | `GET` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | 获取当前企业微信 userid 后匹配 OA 绑定;不能仅靠手机号/邮箱授权登录。 | -| 成员本人敏感资料授权 | [网页授权链接](https://developer.work.weixin.qq.com/document/path/91022)、[访问用户身份](https://developer.work.weixin.qq.com/document/path/91023)、[访问用户敏感信息](https://developer.work.weixin.qq.com/document/path/95833) | `snsapi_privateinfo` + `/cgi-bin/auth/getuserinfo` + `/cgi-bin/auth/getuserdetail` | 浏览器跳转 + `GET/POST` | `WECOM_AGENT_SECRET` 所属应用 | `LOCAL_IMPLEMENTED_EXTERNAL_CONFIG_PENDING` | 普通登录继续用 `snsapi_base`;仅默认关闭的一次性开通入口请求私密授权。成员同意后,服务端用短时 `user_ticket` 立即换取手机及个人邮箱或企业邮箱;票据、code 和原始响应不落库、不写 Cookie、日志或审计。管理员仍需在应用详情选择相应敏感字段,成员需本人确认。 | +| 成员本人敏感资料授权 | [网页授权链接](https://developer.work.weixin.qq.com/document/path/91022)、[访问用户身份](https://developer.work.weixin.qq.com/document/path/91023)、[访问用户敏感信息](https://developer.work.weixin.qq.com/document/path/95833) | `snsapi_privateinfo` + `/cgi-bin/auth/getuserinfo` + `/cgi-bin/auth/getuserdetail` | 浏览器跳转 + `GET/POST` | `WECOM_AGENT_SECRET` 所属应用 | `LOCAL_IMPLEMENTED_EXTERNAL_CONFIG_PENDING` | 普通登录继续用 `snsapi_base`;仅默认关闭的一次性开通入口请求私密授权。成员同意后,服务端用短时 `user_ticket` 立即换取已授权字段;个人邮箱或企业邮箱仍是建档必填,手机号可暂缺,返回时才规范化并检查唯一。票据、code 和原始响应不落库、不写 Cookie、日志或审计。管理员仍需在应用详情选择相应敏感字段,成员需本人确认。 | | 应用消息 | [发送应用消息](https://developer.work.weixin.qq.com/document/path/90236) | `/cgi-bin/message/send` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | OA 主办审批只发脱敏标题、类型、编号、状态和 OA deeplink;不发送高敏附件。 | | 通讯录概述 | [通讯录管理](https://developer.work.weixin.qq.com/document/path/90193) | 通讯录成员、部门、回调和导入导出 | 混合 | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW_PARTIAL` | 通讯录是单独 Secret 域,和工作台应用 Secret 分开管理。 | +| 管理后台通讯录导出 | 企业微信管理后台导出 XLSX | `npm run import:wecom-directory-export -- --input=- --dry-run` | 本地 stdin | 无 Secret | `LOCAL_IMPLEMENTED_NOT_DEPLOYED` | 作为批量 API 缺少敏感字段时的一次性管理员输入;只接受 stdin、默认 dry-run,真实 apply 需参数和环境变量双门及另行批准。企微账号和企业邮箱必填且唯一,手机号可暂缺并保存为 `NULL`;空来源不清除 OA 既有手机号,报告只输出计数和类别,原文件不入 Git。 | | 成员 ID 清单 | [获取成员 ID 列表](https://developer.work.weixin.qq.com/document/path/96067) | `/cgi-bin/user/list_id` | `POST` | `WECOM_CONTACT_SECRET` | `CAN_REUSE_NOW` | WOUA/WHMS 全量候选必须走该接口,请求体只使用 `cursor`/`limit`,返回用户-部门关系后本地按 userid 去重;不再用旧 `/cgi-bin/user/list` 做全量读取。 | | 读取成员 | [读取成员](https://developer.work.weixin.qq.com/document/path/90196) | `/cgi-bin/user/get` | `GET` | `WECOM_AGENT_SECRET` | `OPTIONAL_ENRICHMENT` | 用自建应用 token 补齐 userid 对应的姓名、状态和部门等可见资料。2022-06-20 后新建应用的手机、邮箱和企业邮箱不能以批量接口缺失推断为企微不支持;这些字段按成员本人敏感资料授权链路获取。 | | 获取部门成员 | [获取部门成员](https://developer.work.weixin.qq.com/document/path/90200) | `/cgi-bin/user/simplelist` | `GET` | `WECOM_CONTACT_SECRET` | `LEGACY_NOT_DEFAULT` | 仅作历史参考,当前全量读取不依赖它。 | diff --git a/server/package.json b/server/package.json index 2342ba8e..ec67e519 100644 --- a/server/package.json +++ b/server/package.json @@ -29,6 +29,7 @@ "audit:smoke-data": "node src/scripts/audit-smoke-data.js", "audit:org-master-data": "node src/scripts/audit-org-master-data.js", "audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js", + "import:wecom-directory-export": "node src/scripts/import-wecom-directory-export.js", "repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.js", "check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js", "check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js", diff --git a/server/prisma/migrations/20260727153000_allow_employee_phone_null/migration.sql b/server/prisma/migrations/20260727153000_allow_employee_phone_null/migration.sql new file mode 100644 index 00000000..25e95992 --- /dev/null +++ b/server/prisma/migrations/20260727153000_allow_employee_phone_null/migration.sql @@ -0,0 +1,5 @@ +-- Enterprise WeChat directory exports may omit a member phone number. +-- PostgreSQL unique constraints continue to reject duplicate non-null phones +-- while allowing multiple employees to remain temporarily unfilled. +ALTER TABLE "employees" + ALTER COLUMN "phone" DROP NOT NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b57768fc..fc975a95 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -578,7 +578,7 @@ model Employee { id String @id @default(uuid()) @db.Uuid employee_no String @unique @default(dbgenerated("generate_employee_number()")) name String - phone String @unique + phone String? @unique email String @unique department_id String @db.Uuid legal_entity_id String? @db.Uuid diff --git a/server/src/routes/users.js b/server/src/routes/users.js index ef8eb05a..214782ff 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -58,10 +58,15 @@ const normalizedEmailSchema = z .max(200) .transform((value) => value.toLowerCase()); +const nullablePhoneSchema = z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? null : value), + z.string().trim().min(6).max(30).nullable().optional(), +); + const createBodySchema = z.object({ employeeNo: z.string().trim().min(1).max(50).optional(), name: z.string().min(1).max(100), - phone: z.string().min(6).max(30), + phone: nullablePhoneSchema, email: normalizedEmailSchema, departmentId: z.string().uuid().optional(), title: z.string().min(1).max(100).optional(), @@ -74,7 +79,7 @@ const createBodySchema = z.object({ const updateBodySchema = z.object({ name: z.string().trim().min(1).max(100).optional(), - phone: z.string().trim().min(6).max(30).optional(), + phone: nullablePhoneSchema, email: normalizedEmailSchema.optional(), departmentId: z.string().uuid().optional(), title: z.string().trim().min(1).max(100).optional(), @@ -98,8 +103,8 @@ const sensitiveBodySchema = z }) .strict(); -const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "phone", "email", "departmentName"]; -const USER_IMPORT_OPTIONAL_HEADERS = ["title", "directManagerEmployeeNo", "roles", "hireDate"]; +const USER_IMPORT_REQUIRED_HEADERS = ["employeeNo", "name", "email", "departmentName"]; +const USER_IMPORT_OPTIONAL_HEADERS = ["phone", "title", "directManagerEmployeeNo", "roles", "hireDate"]; const USER_IMPORT_ALLOWED_EXTENSIONS = new Set([".csv", ".xlsx"]); const USER_IMPORT_ALLOWED_MIME_TYPES = new Set([ "text/csv", @@ -773,11 +778,9 @@ function validateImportRowFields(row) { pushImportRowError(row, "name", "too_long"); } - if (!normalized.phone) { - pushImportRowError(row, "phone", "required"); - } else if (normalized.phone.length < 6) { + if (normalized.phone && normalized.phone.length < 6) { pushImportRowError(row, "phone", "too_short"); - } else if (normalized.phone.length > 30) { + } else if (normalized.phone && normalized.phone.length > 30) { pushImportRowError(row, "phone", "too_long"); } @@ -834,7 +837,7 @@ function createDepartmentResolutionMap(departments) { function createEmployeeLookupMaps(employees) { return { byEmployeeNo: new Map(employees.map((item) => [item.employee_no, item])), - byPhone: new Map(employees.map((item) => [item.phone, item])), + byPhone: new Map(employees.filter((item) => item.phone).map((item) => [item.phone, item])), byEmail: new Map(employees.map((item) => [item.email, item])), }; } @@ -912,7 +915,9 @@ async function buildUserImportPreview(prisma, rawRows, options = {}) { pushImportRowError(row, "employeeNo", "duplicate_existing"); } - const phoneConflict = existingLookup.byPhone.get(row.normalized.phone); + const phoneConflict = row.normalized.phone + ? existingLookup.byPhone.get(row.normalized.phone) + : null; if (phoneConflict) { pushImportRowError(row, "phone", "duplicate_existing"); } @@ -1012,7 +1017,7 @@ async function createSystemUser(tx, options) { ? { employee_no: options.employeeNo.trim() } : {}), name: options.name, - phone: options.phone, + phone: options.phone || null, email: options.email.trim().toLowerCase(), department_id: options.departmentId, title: options.title ?? "员工", @@ -1573,7 +1578,7 @@ async function usersRoutes(fastify, opts = {}) { const employee = await createSystemUser(tx, { employeeNo: row.normalized.employeeNo, name: row.normalized.name, - phone: row.normalized.phone, + phone: row.normalized.phone || null, email: row.normalized.email, departmentId: row.resolvedDepartmentId, title: row.normalized.title, @@ -1742,7 +1747,7 @@ async function usersRoutes(fastify, opts = {}) { if (typeof body.name === "string" && body.name !== current.name) { changes.name = { before: current.name, after: body.name }; } - if (typeof body.phone === "string" && body.phone !== current.phone) { + if (Object.prototype.hasOwnProperty.call(body, "phone") && (body.phone ?? null) !== current.phone) { changes.phone = { before: current.phone, after: body.phone }; } if (typeof body.email === "string" && body.email !== current.email) { @@ -1768,7 +1773,7 @@ async function usersRoutes(fastify, opts = {}) { where: { id: userId }, data: { ...(typeof body.name === "string" ? { name: body.name } : {}), - ...(typeof body.phone === "string" ? { phone: body.phone } : {}), + ...(Object.prototype.hasOwnProperty.call(body, "phone") ? { phone: body.phone ?? null } : {}), ...(typeof body.email === "string" ? { email: body.email } : {}), ...(typeof body.departmentId === "string" ? { department_id: body.departmentId } : {}), ...(typeof body.title === "string" ? { title: body.title } : {}), diff --git a/server/src/scripts/import-wecom-directory-export.js b/server/src/scripts/import-wecom-directory-export.js new file mode 100644 index 00000000..6fc18841 --- /dev/null +++ b/server/src/scripts/import-wecom-directory-export.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node +require("dotenv/config"); + +const fs = require("node:fs"); +const ExcelJS = require("exceljs"); + +const { createPrismaClient } = require("../prismaClient"); +const { + maskEmail, + maskExternalUserId, + maskPhone, + runWecomHrMasterSync, +} = require("../services/integrations/wecomHrMaster"); + +const APPLY_CONFIRM_ENV = "WECOM_DIRECTORY_EXPORT_APPLY"; +const REQUIRED_HEADERS = ["姓名", "账号", "部门", "企业邮箱"]; +const SAFE_EMPTY_PHONE = null; + +class DirectoryExportError extends Error { + constructor(code, details = {}) { + super(code); + this.name = "DirectoryExportError"; + this.code = code; + this.details = details; + } +} + +function printHelp() { + process.stdout.write(`Usage: + node src/scripts/import-wecom-directory-export.js [--input=-] [--dry-run] [--format=json] + WECOM_DIRECTORY_EXPORT_APPLY=1 node src/scripts/import-wecom-directory-export.js --input=- --apply --actor-employee-no= + +Reads an Enterprise WeChat directory XLSX from stdin. Dry-run is the default. +Reports contain counts and categories only; personal field values are never printed. +`); +} + +function parseArgs(argv) { + const options = { + input: "-", + mode: "dry_run", + format: "text", + actorEmployeeNo: "", + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") options.help = true; + else if (arg === "--dry-run") options.mode = "dry_run"; + else if (arg === "--apply") options.mode = "apply"; + else if (arg === "--input") options.input = argv[++index] ?? ""; + else if (arg.startsWith("--input=")) options.input = arg.slice("--input=".length); + else if (arg === "--format") options.format = argv[++index] ?? ""; + else if (arg.startsWith("--format=")) options.format = arg.slice("--format=".length); + else if (arg === "--actor-employee-no") options.actorEmployeeNo = argv[++index] ?? ""; + else if (arg.startsWith("--actor-employee-no=")) { + options.actorEmployeeNo = arg.slice("--actor-employee-no=".length); + } else throw new DirectoryExportError("unsupported_argument"); + } + if (options.input !== "-") throw new DirectoryExportError("stdin_required"); + if (!new Set(["text", "json"]).has(options.format)) throw new DirectoryExportError("invalid_format"); + if (options.mode === "apply") { + if (process.env[APPLY_CONFIRM_ENV] !== "1") throw new DirectoryExportError("apply_confirmation_required"); + if (!/^[A-Za-z0-9_-]{1,80}$/.test(options.actorEmployeeNo)) { + throw new DirectoryExportError("actor_required"); + } + } + return options; +} + +function cellToString(value) { + if (value == null) return ""; + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value === "object") { + if (Array.isArray(value.richText)) return value.richText.map((item) => item.text ?? "").join("").trim(); + if (value.text != null) return String(value.text).trim(); + if (value.result != null) return cellToString(value.result); + } + return String(value).trim(); +} + +function findHeaderRow(worksheet) { + const maximum = Math.min(50, worksheet.rowCount); + for (let rowNumber = 1; rowNumber <= maximum; rowNumber += 1) { + const labels = worksheet.getRow(rowNumber).values.slice(1).map(cellToString); + if (REQUIRED_HEADERS.every((header) => labels.includes(header))) { + return { rowNumber, labels }; + } + } + throw new DirectoryExportError("required_headers_missing", { missingHeaderCount: REQUIRED_HEADERS.length }); +} + +function normalizeEmail(value) { + const raw = cellToString(value).toLowerCase(); + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw) ? raw : ""; +} + +function normalizePhone(value) { + const raw = cellToString(value); + if (!raw) return { value: SAFE_EMPTY_PHONE, invalid: false }; + const normalized = raw.replace(/[\s-]/g, ""); + if (!/^\+?\d{6,30}$/.test(normalized)) { + return { value: SAFE_EMPTY_PHONE, invalid: true }; + } + return { value: normalized, invalid: false }; +} + +function buildProfile(data) { + const externalUserId = cellToString(data["账号"]).slice(0, 128); + const name = cellToString(data["姓名"]).slice(0, 100); + const phone = normalizePhone(data["手机"]); + const email = normalizeEmail(data["企业邮箱"]); + const departmentName = cellToString(data["部门"]).slice(0, 100); + const title = cellToString(data["职务"]).slice(0, 100) || "员工"; + const activation = cellToString(data["激活状态"]); + const disabled = cellToString(data["禁用状态"]); + const active = (!activation || activation === "已激活") + && (!disabled || new Set(["否", "未禁用", "正常", "0"]).has(disabled)); + const missingRequiredFields = []; + for (const [field, value] of [ + ["externalUserId", externalUserId], + ["name", name], + ["email", email], + ["department", departmentName], + ]) { + if (!value) missingRequiredFields.push(field); + } + if (phone.invalid) missingRequiredFields.push("phone_invalid"); + return { + externalUserId, + name, + phone: phone.value, + email, + title, + departmentExternalId: departmentName, + departmentName, + managerExternalUserId: "", + hireDate: null, + sourceStatus: active ? "active" : "inactive", + missingRequiredFields, + sourceSummary: { + externalUserIdPrefix: maskExternalUserId(externalUserId), + phoneMasked: maskPhone(phone.value), + emailMasked: maskEmail(email), + hasPhone: Boolean(phone.value), + hasEmail: Boolean(email), + hasDepartment: Boolean(departmentName), + sourceStatus: active ? "active" : "inactive", + sourceKind: "wecom_directory_export", + }, + }; +} + +function countDuplicateValues(profiles, key) { + const counts = new Map(); + for (const profile of profiles) { + const value = String(profile[key] ?? "").trim().toLowerCase(); + if (!value) continue; + counts.set(value, (counts.get(value) ?? 0) + 1); + } + return [...counts.values()].filter((count) => count > 1).length; +} + +async function parseDirectoryExport(buffer) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer); + const worksheet = workbook.worksheets[0]; + if (!worksheet) throw new DirectoryExportError("worksheet_missing"); + const header = findHeaderRow(worksheet); + const profiles = []; + worksheet.eachRow((row, rowNumber) => { + if (rowNumber <= header.rowNumber) return; + const data = {}; + header.labels.forEach((label, index) => { + if (label) data[label] = cellToString(row.getCell(index + 1).value); + }); + if (Object.values(data).some(Boolean)) profiles.push(buildProfile(data)); + }); + if (!profiles.length) throw new DirectoryExportError("member_rows_missing"); + + const duplicateCounts = { + account: countDuplicateValues(profiles, "externalUserId"), + phone: countDuplicateValues(profiles, "phone"), + email: countDuplicateValues(profiles, "email"), + }; + if (Object.values(duplicateCounts).some((count) => count > 0)) { + throw new DirectoryExportError("duplicate_values", duplicateCounts); + } + return { + profiles, + sourceSummary: { + memberCount: profiles.length, + activeCount: profiles.filter((profile) => profile.sourceStatus === "active").length, + phonePresentCount: profiles.filter((profile) => Boolean(profile.phone)).length, + phoneMissingCount: profiles.filter((profile) => !profile.phone).length, + emailPresentCount: profiles.filter((profile) => Boolean(profile.email)).length, + incompleteCount: profiles.filter((profile) => profile.missingRequiredFields.length > 0).length, + }, + }; +} + +async function resolveActorId(prisma, employeeNo) { + if (!employeeNo) return null; + const employee = await prisma.employee.findUnique({ + where: { employee_no: employeeNo }, + select: { id: true, status: true, archived_at: true }, + }); + if (!employee || employee.status !== "active" || employee.archived_at) { + throw new DirectoryExportError("actor_unavailable"); + } + return employee.id; +} + +function buildSafeReport(options, parsed, syncRun) { + const summary = syncRun?.summary ?? {}; + const blocked = Number(summary.failed ?? 0) > 0 + || Number(summary.conflicts ?? 0) > 0 + || Number(summary.skipped ?? 0) > 0 + || parsed.sourceSummary.incompleteCount > 0; + return { + issueId: "WECOM_DIRECTORY_EXPORT", + status: blocked + ? "BLOCKED_DIRECTORY_EXPORT" + : options.mode === "apply" + ? "PASS_DIRECTORY_EXPORT_APPLY" + : "PASS_DIRECTORY_EXPORT_DRY_RUN", + mode: options.mode, + source: parsed.sourceSummary, + sync: { + created: Number(summary.created ?? 0), + updated: Number(summary.updated ?? 0), + noop: Number(summary.noop ?? 0), + skipped: Number(summary.skipped ?? 0), + conflicts: Number(summary.conflicts ?? 0), + failed: Number(summary.failed ?? 0), + planned: Number(summary.planned ?? 0), + success: Number(summary.success ?? 0), + }, + privacy: "counts/categories only; no names, accounts, phones, emails, departments, paths, or raw rows", + }; +} + +function printReport(report, format) { + if (format === "json") { + process.stdout.write(`${JSON.stringify(report)}\n`); + return; + } + process.stdout.write(`[wecom-directory-export] status=${report.status} mode=${report.mode}\n`); + process.stdout.write(`[wecom-directory-export] members=${report.source.memberCount} phonePresent=${report.source.phonePresentCount} phoneMissing=${report.source.phoneMissingCount} incomplete=${report.source.incompleteCount}\n`); + process.stdout.write(`[wecom-directory-export] planned=${report.sync.planned} conflicts=${report.sync.conflicts} skipped=${report.sync.skipped} failed=${report.sync.failed}\n`); +} + +async function main() { + let resources = null; + try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const parsed = await parseDirectoryExport(await fs.promises.readFile(0)); + resources = createPrismaClient(); + const actorId = await resolveActorId(resources.prisma, options.actorEmployeeNo); + const syncRun = await runWecomHrMasterSync({ + prisma: resources.prisma, + config: null, + actorId, + mode: options.mode, + limit: parsed.profiles.length, + sourceProfiles: parsed.profiles, + sourceLabel: "manual_export", + }); + const report = buildSafeReport(options, parsed, syncRun); + printReport(report, options.format); + if (report.status.startsWith("BLOCKED")) process.exitCode = 2; + } catch (error) { + const code = error instanceof DirectoryExportError ? error.code : "directory_export_failed"; + process.stderr.write(`[wecom-directory-export][ERROR] ${code}\n`); + process.exitCode = 1; + } finally { + await resources?.prisma?.$disconnect?.().catch(() => {}); + await resources?.pool?.end?.().catch(() => {}); + } +} + +if (require.main === module) main(); + +module.exports = { + DirectoryExportError, + buildProfile, + buildSafeReport, + countDuplicateValues, + findHeaderRow, + parseArgs, + parseDirectoryExport, +}; diff --git a/server/src/services/integrations/wecomHrMaster.js b/server/src/services/integrations/wecomHrMaster.js index f69fb216..b4935fc7 100644 --- a/server/src/services/integrations/wecomHrMaster.js +++ b/server/src/services/integrations/wecomHrMaster.js @@ -468,7 +468,7 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new }; profile.missingRequiredFields = []; - for (const field of ["externalUserId", "name", "phone", "email"]) { + for (const field of ["externalUserId", "name", "email"]) { if (!isMeaningfulString(profile[field])) profile.missingRequiredFields.push(field); } if (!isMeaningfulString(profile.departmentExternalId) && !isMeaningfulString(profile.departmentName)) { @@ -721,18 +721,21 @@ async function detectUniqueConflicts(prisma, employeeId, profile) { function buildEmployeeChanges(employee, profile, department) { if (!employee) { - return { + const changes = { name: buildChange("name", null, profile.name), - phone: buildChange("phone", null, profile.phone), email: buildChange("email", null, profile.email), department: buildChange("department", null, department.name), title: buildChange("title", null, profile.title), }; + if (profile.phone) changes.phone = buildChange("phone", null, profile.phone); + return changes; } const changes = {}; if (employee.name !== profile.name) changes.name = buildChange("name", employee.name, profile.name); - if (employee.phone !== profile.phone) changes.phone = buildChange("phone", employee.phone, profile.phone); + if (profile.phone && employee.phone !== profile.phone) { + changes.phone = buildChange("phone", employee.phone, profile.phone); + } if (employee.email !== profile.email) changes.email = buildChange("email", employee.email, profile.email); if (department.id && employee.department_id !== department.id) { changes.department = buildChange("department", employee.department_id, department.name); @@ -854,7 +857,7 @@ async function processProfile(prisma, runId, profile, context) { where: { id: existing.id }, data: { name: profile.name, - phone: profile.phone, + ...(profile.phone ? { phone: profile.phone } : {}), email: profile.email, department_id: department.id, title: profile.title, @@ -866,7 +869,7 @@ async function processProfile(prisma, runId, profile, context) { : await prisma.employee.create({ data: { name: profile.name, - phone: profile.phone, + phone: profile.phone || null, email: profile.email, department_id: department.id, title: profile.title, @@ -1077,6 +1080,8 @@ async function runWecomHrMasterSync({ userids: rawUserids, limit: rawLimit, fetchImpl = fetch, + sourceProfiles = null, + sourceLabel = "manual_export", }) { const mode = normalizeMode(rawMode); const userids = normalizeUserids(rawUserids); @@ -1095,12 +1100,17 @@ async function runWecomHrMasterSync({ let source = null; try { - source = await fetchWecomHrProfiles({ - config, - userids, - limit, - fetchImpl, - }); + source = Array.isArray(sourceProfiles) + ? { + profiles: sourceProfiles.slice(0, limit), + tokenSource: { offline: toSafeString(sourceLabel, 40) || "manual_export" }, + } + : await fetchWecomHrProfiles({ + config, + userids, + limit, + fetchImpl, + }); } catch (error) { const status = error instanceof WecomHrMasterSyncError && error.code === "wecom_token_unavailable" ? "skipped" diff --git a/server/src/services/integrations/wecomOAuthOnboarding.js b/server/src/services/integrations/wecomOAuthOnboarding.js index 8aea2200..de6e9d94 100644 --- a/server/src/services/integrations/wecomOAuthOnboarding.js +++ b/server/src/services/integrations/wecomOAuthOnboarding.js @@ -115,7 +115,7 @@ async function fetchWecomOAuthOnboardingProfile({ }; const missing = []; - for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) { + for (const field of ["name", "email", "departmentExternalId", "departmentName"]) { if (!profile[field]) missing.push(field); } if (missing.length) { @@ -186,7 +186,12 @@ async function resolveEmployee(tx, profile) { }, }); const contactMatches = await tx.employee.findMany({ - where: { OR: [{ phone: profile.phone }, { email: profile.email }] }, + where: { + OR: [ + ...(profile.phone ? [{ phone: profile.phone }] : []), + { email: profile.email }, + ], + }, select: { id: true, status: true, @@ -229,7 +234,7 @@ async function provisionWecomOAuthEmployee({ where: { id: existing.id }, data: { name: profile.name, - phone: profile.phone, + ...(profile.phone ? { phone: profile.phone } : {}), email: profile.email, department_id: departmentId, title: profile.title, @@ -241,7 +246,7 @@ async function provisionWecomOAuthEmployee({ : await tx.employee.create({ data: { name: profile.name, - phone: profile.phone, + phone: profile.phone || null, email: profile.email, department_id: departmentId, title: profile.title, @@ -294,7 +299,7 @@ async function provisionWecomOAuthEmployee({ extra: { created: !existing, source: "wecom_oauth", - fieldPresence: { phone: true, email: true }, + fieldPresence: { phone: Boolean(profile.phone), email: true }, }, }), }); diff --git a/server/tests/unit/routes/users.import.test.js b/server/tests/unit/routes/users.import.test.js index 4f55e0cd..1c150a4c 100644 --- a/server/tests/unit/routes/users.import.test.js +++ b/server/tests/unit/routes/users.import.test.js @@ -219,6 +219,33 @@ describe("users import routes", () => { await app.close(); }); + it("accepts an import without a phone header and persists a missing phone as null", async () => { + const { app, employeeCreate } = await buildApp({ + departments: [{ id: "dept-1", name: "行政部", archived_at: null }], + }); + const multipartRequest = buildMultipartRequest({ + filename: "users.csv", + contentType: "text/csv", + content: [ + "employeeNo,name,email,departmentName,title,roles,hireDate", + "E006,NoPhone,nophone@example.com,行政部,员工,employee,2026-07-27", + ].join("\n"), + }); + + const response = await app.inject({ + method: "POST", + url: "/api/users/import/apply", + ...multipartRequest, + }); + + expect(response.statusCode).toBe(200); + expect(employeeCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ phone: null }), + select: expect.any(Object), + }); + await app.close(); + }); + it("rejects dry-run uploads with unsupported mime type", async () => { const { app } = await buildApp(); const multipartRequest = buildMultipartRequest({ diff --git a/server/tests/unit/routes/users.write.test.js b/server/tests/unit/routes/users.write.test.js index 456233d9..88661ae6 100644 --- a/server/tests/unit/routes/users.write.test.js +++ b/server/tests/unit/routes/users.write.test.js @@ -62,7 +62,27 @@ async function buildApp({ roles: [{ role_code: "employee" }], }, ); - const employeeUpdate = vi.fn(); + const employeeUpdate = vi.fn(async ({ data }) => ({ + ...(currentEmployee ?? { + id: USER_ID, + employee_no: "HYD000321", + name: "Alice", + phone: "13800000000", + email: "alice@example.com", + status: "active", + department_id: DEPARTMENT_ID, + title: "员工", + direct_manager_id: null, + hire_date: new Date("2026-05-01T00:00:00.000Z"), + archived_at: null, + created_at: new Date("2026-05-01T00:00:00.000Z"), + updated_at: new Date("2026-05-01T00:00:00.000Z"), + roles: [{ role_code: "employee" }], + wecom_identity_binding: null, + wecom_directory_sync_runs: [], + }), + ...data, + })); const employeeRoleCreateMany = vi.fn().mockResolvedValue({ count: 1 }); const employeeRoleFindMany = vi.fn().mockResolvedValue(currentRoles.map((role_code) => ({ role_code }))); const employeeRoleDeleteMany = vi.fn().mockResolvedValue({ count: currentRoles.length }); @@ -139,10 +159,6 @@ async function buildApp({ version: 1, }), }, - wecomDirectorySyncRun: { - create: wecomSyncRunCreate, - }, - wecomIdentityBinding: {}, $transaction: vi.fn(async (callback) => callback(tx)), }); app.decorate("requirePermission", (permissionCode) => async (request) => { @@ -243,6 +259,45 @@ describe("usersRoutes write flows", () => { await app.close(); }); + it("creates a user with a null phone when the field is omitted", async () => { + const { app, employeeCreate } = await buildApp(); + + const response = await app.inject({ + method: "POST", + url: "/api/users", + payload: { + name: "暂缺手机号成员", + email: "no-phone@example.com", + roles: ["employee"], + syncWecomDirectory: false, + }, + }); + + expect(response.statusCode).toBe(200); + expect(employeeCreate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ phone: null }), + })); + + await app.close(); + }); + + it("allows an administrator to explicitly clear an existing phone to null", async () => { + const { app, employeeUpdate } = await buildApp(); + + const response = await app.inject({ + method: "PATCH", + url: `/api/users/${USER_ID}`, + payload: { phone: null }, + }); + + expect(response.statusCode).toBe(200); + expect(employeeUpdate).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ phone: null }), + })); + + await app.close(); + }); + it("rejects employeeNo changes on patch", async () => { const { app, employeeFindUnique, employeeUpdate } = await buildApp(); diff --git a/server/tests/unit/scripts/import-wecom-directory-export.test.js b/server/tests/unit/scripts/import-wecom-directory-export.test.js new file mode 100644 index 00000000..f981aa6c --- /dev/null +++ b/server/tests/unit/scripts/import-wecom-directory-export.test.js @@ -0,0 +1,169 @@ +const ExcelJS = require("exceljs"); + +const { + DirectoryExportError, + buildSafeReport, + parseArgs, + parseDirectoryExport, +} = require("../../../src/scripts/import-wecom-directory-export"); + +async function buildDirectoryWorkbook(rows) { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("通讯录"); + worksheet.addRow(["填写须知:本行为企微导出说明"]); + worksheet.addRow([]); + worksheet.addRow([ + "姓名", "账号", "别名", "职务", "部门", "性别", "手机", "座机", + "邮箱", "地址", "企业邮箱", "视频号", "激活状态", "禁用状态", "微信插件", + ]); + for (const row of rows) { + worksheet.addRow([ + row.name, + row.account, + "", + row.title ?? "员工", + row.department ?? "总部", + "", + row.phone ?? "", + "", + row.email ?? "", + "", + row.businessEmail, + "", + "已激活", + "", + "未关注", + ]); + } + return Buffer.from(await workbook.xlsx.writeBuffer()); +} + +describe("WeCom directory export importer", () => { + it("requires stdin, the apply environment gate, and an actor for apply", () => { + const previous = process.env.WECOM_DIRECTORY_EXPORT_APPLY; + delete process.env.WECOM_DIRECTORY_EXPORT_APPLY; + try { + expect(() => parseArgs(["--input=directory.xlsx"])).toThrowError( + expect.objectContaining({ code: "stdin_required" }), + ); + expect(() => parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toThrowError( + expect.objectContaining({ code: "apply_confirmation_required" }), + ); + process.env.WECOM_DIRECTORY_EXPORT_APPLY = "1"; + expect(() => parseArgs(["--apply"])).toThrowError( + expect.objectContaining({ code: "actor_required" }), + ); + expect(parseArgs(["--apply", "--actor-employee-no=HYD000001"])).toEqual( + expect.objectContaining({ input: "-", mode: "apply", actorEmployeeNo: "HYD000001" }), + ); + } finally { + if (previous == null) delete process.env.WECOM_DIRECTORY_EXPORT_APPLY; + else process.env.WECOM_DIRECTORY_EXPORT_APPLY = previous; + } + }); + + it("finds the official Chinese header after instruction rows and accepts one missing phone", async () => { + const rows = Array.from({ length: 6 }, (_, index) => ({ + name: `合成员${index + 1}`, + account: `member-${index + 1}`, + phone: index === 5 ? "" : `1990000000${index + 1}`, + businessEmail: `member-${index + 1}@example.test`, + })); + const parsed = await parseDirectoryExport(await buildDirectoryWorkbook(rows)); + + expect(parsed.sourceSummary).toEqual({ + memberCount: 6, + activeCount: 6, + phonePresentCount: 5, + phoneMissingCount: 1, + emailPresentCount: 6, + incompleteCount: 0, + }); + expect(parsed.profiles[5].phone).toBeNull(); + expect(parsed.profiles[5].missingRequiredFields).toEqual([]); + + const report = buildSafeReport( + { mode: "dry_run" }, + parsed, + { summary: { created: 5, updated: 1, planned: 6 } }, + ); + const serialized = JSON.stringify(report); + expect(report.status).toBe("PASS_DIRECTORY_EXPORT_DRY_RUN"); + for (const row of rows) { + expect(serialized).not.toContain(row.name); + expect(serialized).not.toContain(row.account); + expect(serialized).not.toContain(row.businessEmail); + if (row.phone) expect(serialized).not.toContain(row.phone); + } + }); + + it.each([ + ["account", { account: "member-1", phone: "19900000002", businessEmail: "member-2@example.test" }], + ["phone", { account: "member-2", phone: "19900000001", businessEmail: "member-2@example.test" }], + ["email", { account: "member-2", phone: "19900000002", businessEmail: "member-1@example.test" }], + ])("rejects duplicate %s values without returning the duplicated value", async (_field, duplicateRow) => { + const buffer = await buildDirectoryWorkbook([ + { + name: "合成员一", + account: "member-1", + phone: "19900000001", + businessEmail: "member-1@example.test", + }, + { name: "合成员二", ...duplicateRow }, + ]); + + let caught; + try { + await parseDirectoryExport(buffer); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(DirectoryExportError); + expect(caught.code).toBe("duplicate_values"); + const serialized = JSON.stringify(caught); + expect(serialized).not.toContain("member-1@example.test"); + expect(serialized).not.toContain("19900000001"); + }); + + it("requires a valid enterprise email even when a personal email is present", async () => { + const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ + name: "合成员一", + account: "member-1", + phone: "19900000001", + email: "personal@example.test", + businessEmail: "", + }])); + + expect(parsed.sourceSummary.incompleteCount).toBe(1); + expect(parsed.profiles[0].missingRequiredFields).toContain("email"); + }); + + it("blocks a supplied invalid phone instead of treating it as an absent phone", async () => { + const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ + name: "合成员一", + account: "member-1", + phone: "not-a-phone", + businessEmail: "member-1@example.test", + }])); + + expect(parsed.sourceSummary.phoneMissingCount).toBe(1); + expect(parsed.sourceSummary.incompleteCount).toBe(1); + expect(parsed.profiles[0].missingRequiredFields).toContain("phone_invalid"); + }); + + it("uses a distinct success status for an apply report", async () => { + const parsed = await parseDirectoryExport(await buildDirectoryWorkbook([{ + name: "合成员一", + account: "member-1", + phone: "", + businessEmail: "member-1@example.test", + }])); + + const report = buildSafeReport( + { mode: "apply" }, + parsed, + { summary: { created: 1, success: 1 } }, + ); + expect(report.status).toBe("PASS_DIRECTORY_EXPORT_APPLY"); + }); +}); diff --git a/server/tests/unit/wecomHrMaster.service.test.js b/server/tests/unit/wecomHrMaster.service.test.js index 11d503f7..7e1f8a79 100644 --- a/server/tests/unit/wecomHrMaster.service.test.js +++ b/server/tests/unit/wecomHrMaster.service.test.js @@ -67,6 +67,38 @@ describe("wecom HR master sync helpers", () => { expect(profile.missingRequiredFields).toContain("email"); }); + it("accepts a profile with no phone and does not plan clearing an existing phone", () => { + const profile = normalizeWecomHrProfile({ + contact: { + userid: "missing-phone", + name: "缺手机号成员", + biz_mail: "missing-phone@hyinda.com", + department: [1], + enable: 1, + }, + staff: {}, + departmentMap: new Map([["1", { externalId: "1", name: "行政部" }]]), + }); + + expect(profile.phone).toBe(""); + expect(profile.missingRequiredFields).toEqual([]); + + const changes = __testables.buildEmployeeChanges( + { + id: "employee-1", + name: profile.name, + phone: "19900000009", + email: profile.email, + department_id: "department-1", + title: "员工", + wecom_identity_binding: { external_user_id: profile.externalUserId }, + }, + profile, + { id: "department-1", name: "行政部" }, + ); + expect(changes).not.toHaveProperty("phone"); + }); + it("marks inactive WeCom members as confirmation-required instead of active", () => { const profile = normalizeWecomHrProfile({ contact: { diff --git a/server/tests/unit/wecomOAuthOnboarding.service.test.js b/server/tests/unit/wecomOAuthOnboarding.service.test.js index 3ee0ed16..7abbc477 100644 --- a/server/tests/unit/wecomOAuthOnboarding.service.test.js +++ b/server/tests/unit/wecomOAuthOnboarding.service.test.js @@ -101,6 +101,20 @@ describe("wecom OAuth onboarding service", () => { }); expect(profile.email).toBe("business.user@example.test"); + const profileWithoutPhone = await fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-fields", + userTicket: "ticket", + fetchImpl: buildFetch({ + errcode: 0, + userid: "member-fields", + mobile: "", + biz_mail: "Business.User@Example.test", + }), + }); + expect(profileWithoutPhone.phone).toBe(""); + expect(profileWithoutPhone.email).toBe("business.user@example.test"); + await expect(fetchWecomOAuthOnboardingProfile({ accessToken: "agent-token", externalUserId: "member-fields", @@ -238,6 +252,52 @@ describe("wecom OAuth onboarding service", () => { expect(persisted).not.toContain("member1@example.test"); }); + it("creates a member with a null phone when the authorized profile has no phone", async () => { + const tx = { + wecomIdentityBinding: { + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), + }, + employee: { + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 0 }), + }, + integrationBinding: { + findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }), + }, + department: { + findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }), + }, + employeeRole: { create: vi.fn().mockResolvedValue({}) }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) }; + + await provisionWecomOAuthEmployee({ + prisma, + request: buildRequest(), + profile: { + externalUserId: "member-no-phone", + name: "缺手机号成员", + phone: "", + email: "member-no-phone@example.test", + departmentExternalId: "1", + departmentName: "总部", + title: "员工", + }, + }); + + expect(tx.employee.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ phone: null }), + select: expect.any(Object), + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + extra: expect.objectContaining({ fieldPresence: { phone: false, email: true } }), + }), + }); + }); + it("rejects contact data that resolves to multiple OA employees", async () => { const tx = { wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) },