From 130518f471ea6449e8b74ffce3e98f34fe17cb69 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 26 Jul 2026 23:44:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BC=81=E5=BE=AE=E5=BC=80?= =?UTF-8?q?=E9=80=9A=E4=B8=8E=E9=83=A8=E7=BD=B2=E7=AB=AF=E5=8F=A3=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=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/deployment-trialrun.md | 75 ++++ .trellis/spec/hyb-oa/wecom-boundary.md | 90 +++++ .../design.md | 65 ++++ .../implement.md | 27 ++ .../prd.md | 46 ++- .../research/live-read-only-baseline.md | 10 + deploy/trialrun/.env.example | 3 + deploy/trialrun/checklist.md | 8 +- deploy/trialrun/deploy.sh | 10 +- deploy/trialrun/docker-compose.yml | 2 +- deploy/trialrun/runbook.md | 23 +- deploy/trialrun/setup-server.sh | 8 +- deploy/trialrun/validate-env-contract.js | 4 +- docs/企微API能力目录.md | 13 +- server/src/config.js | 1 + server/src/routes/wecomAuth.js | 103 +++++- .../services/integrations/wecomHrMaster.js | 12 +- .../integrations/wecomOAuthOnboarding.js | 316 ++++++++++++++++ server/tests/unit/routes/wecomAuth.test.js | 218 +++++++++++ .../tests/unit/wecomHrMaster.service.test.js | 26 +- .../unit/wecomOAuthOnboarding.service.test.js | 345 ++++++++++++++++++ 21 files changed, 1350 insertions(+), 55 deletions(-) create mode 100644 server/src/services/integrations/wecomOAuthOnboarding.js create mode 100644 server/tests/unit/wecomOAuthOnboarding.service.test.js diff --git a/.trellis/spec/hyb-oa/deployment-trialrun.md b/.trellis/spec/hyb-oa/deployment-trialrun.md index 22719281..8c2ae8d1 100644 --- a/.trellis/spec/hyb-oa/deployment-trialrun.md +++ b/.trellis/spec/hyb-oa/deployment-trialrun.md @@ -517,6 +517,81 @@ bash scripts/qa/run-local-candidate.sh full # Only this successful branch emits LOCAL_CANDIDATE_PASS. ``` +## Scenario: Compose-Resolved Loopback Host Port + +### 1. Scope / Trigger + +- Trigger: changing Compose port publication, deployment health checks, Caddy + routing or operator commands for the server container. +- Purpose: support a non-default host port without changing the container's + fixed application port or accidentally checking a stale service. + +### 2. Signatures + +- Env: `APP_HOST_PORT=<1-65535>`; default `3000`. +- Compose mapping: `127.0.0.1:${APP_HOST_PORT:-3000}:3000`. +- Runtime resolution: `docker compose port server 3000` or the compatible + `docker-compose` command selected by the deployment script. +- Backend container still listens on `PORT=3000`. + +### 3. Contracts + +- Publication remains loopback-only. `APP_HOST_PORT` changes only the host side; + Caddy points to the same resolved host port. +- Deployment health checks must parse the running Compose mapping after startup; + do not hardcode 3000 and do not assume `.env` variables entered the current + shell merely because Compose read `--env-file`. +- The parsed port must be numeric before it is used in curl. A missing, + multi-line or non-numeric mapping stops deployment before readiness success. +- `server`, `worker` and `init` retain the shared env-file contract; only server + publishes an HTTP host port. +- Changing the real host port or Caddy target is production configuration and + requires separate approval plus rollback evidence. + +### 4. Validation & Error Matrix + +- Missing `APP_HOST_PORT` -> Compose uses loopback port 3000. +- Valid alternate port -> Compose publishes it and deployment checks that port. +- Non-numeric/unresolved `compose port` output -> deployment exits non-zero. +- Caddy target differs from Compose mapping -> deployment is not accepted even + if one local `/ready` endpoint answers. +- Host port already occupied -> Compose startup fails; do not start a second + deployment on a guessed port. + +### 5. Good/Base/Bad Cases + +- Good: `.env` selects an alternate loopback port, Compose reports it, deploy + checks the matching build SHA there and Caddy targets the same port. +- Base: default port 3000 remains compatible without extra configuration. +- Bad: Compose publishes 3001 while deploy/runbook curls 3000 and reports an old + container as the new release. + +### 6. Tests Required + +- `bash -n deploy/trialrun/deploy.sh deploy/trialrun/setup-server.sh`. +- `node deploy/trialrun/validate-env-contract.js` must assert the exact + loopback-only variable mapping and env example key. +- Review runbook/checklist commands for Compose resolution rather than shell + expansion of an unloaded `.env`. +- Public deployment additionally proves Caddy, `/ready` and frontend marker use + the approved build on the same resolved port. + +### 7. Wrong vs Correct + +#### Wrong + +```bash +curl -fsS http://127.0.0.1:3000/ready +``` + +#### Correct + +```bash +SERVER_HOST_PORT="$(docker compose port server 3000)" +SERVER_HOST_PORT="${SERVER_HOST_PORT##*:}" +curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" +``` + ## Scenario: Paired Backup And Shadow-Only Restore ### 1. Scope / Trigger diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 05947c0b..74fb52f2 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -142,3 +142,93 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history - Full-stack Playwright coverage that writes local data still requires a running local backend/API environment; a Vite-only run is not a valid failure signal for those real-chain tests. + +## Scenario: One-Time Private-Info OAuth Onboarding + +### 1. Scope / Trigger + +- Trigger: obtaining phone/email for an app-visible member or allowing an + unbound active member to create the minimum OA account. +- This is a one-time onboarding path, not the ordinary WeCom login path and not + a replacement for administrator-assigned finance, CEO or admin roles. + +### 2. Signatures + +- Entry: `GET /api/auth/wecom/login?mode=onboard&returnTo=`. +- Ordinary entry: the same route with `mode=login|bind`. +- Provider chain: `snsapi_privateinfo` -> `/cgi-bin/auth/getuserinfo` -> + `/cgi-bin/auth/getuserdetail` with `{ "user_ticket": "" }`. +- Config: `WECOM_OAUTH_ONBOARDING_ENABLED=false` by default. +- Directory chain: `WECOM_CONTACT_SECRET` only for `/user/list_id`; + `WECOM_AGENT_SECRET` for `/user/get`, `/department/list` and + `/hr/get_staff_info`. + +### 3. Contracts + +- `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. +- `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. +- Resolve userid, phone and email conflicts before writing. A member already + bound to another employee or an employee bound to another member is rejected. +- New accounts are created transactionally with employee, exactly the + `employee` role, identity binding and dehydrated audit. Existing active + accounts may complete normalized profile data and binding but keep all roles. +- The feature flag controls the entry and callback provisioning path. Enabling + it and changing real app permissions are production configuration operations + requiring separate approval. + +### 4. Validation & Error Matrix + +- Flag disabled -> entry is `404`; no private scope or account write. +- Missing `user_ticket` -> safe `consent_required` redirect; no write. +- 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`. +- 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`. + +### 5. Good/Base/Bad Cases + +- Good: an unbound active member explicitly consents, receives an employee-only + account and authenticated session, then an administrator separately assigns + any business role with existing audit/session-revocation semantics. +- Base: a bound active employee repeats onboarding to complete normalized + profile data; no role is added or removed. +- Bad: change ordinary login to `snsapi_privateinfo`, use batch `/user/get` + absence as proof that email is unavailable, or auto-grant `finance`/`ceo`. + +### 6. Tests Required + +- Route tests for default-off, scope separation, missing ticket, safe provider + failure, successful session and employee-only provisioning. +- Service tests for active state, userid match, personal-to-business email + fallback, invalid fields, multiple matches, cross-binding and existing-role + preservation. +- Assertions must prove ticket, token, raw payload, phone and email are absent + from audit/error serialization; run full backend tests and Prisma validation. + +### 7. Wrong vs Correct + +#### Wrong + +```text +ordinary login -> snsapi_privateinfo -> batch sync guesses missing fields +-> auto-grant finance/ceo +``` + +#### Correct + +```text +ordinary login -> snsapi_base +one-time onboard -> explicit private consent -> safe unique employee-only write +business role assignment -> separate administrator-audited operation +``` 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 77b31b1c..a86279ae 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/design.md @@ -59,3 +59,68 @@ isolated drill ## 外部门禁 以下事项不因本地演练通过而自动获批:读取真实运行环境、复制真实备份、建立异机定时任务、停机、恢复真实库/附件、部署和公网 `/ready`。进入这些步骤前再次列出目标、只读/写入范围和回滚方式并取得明确批准。 + +## 企微 HR 双 Secret 数据流 + +```text +通讯录 Secret -> user/list_id -> userid 集合 +自建应用 Secret -> user/get + department/list + hr/get_staff_info + -> normalize -> 脱敏 dry-run -> 既有 HR 同步写入 +``` + +- 保留 `WECOM_CONTACT_SECRET` 与 `WECOM_AGENT_SECRET` 的现有配置名和 token 来源审计,不增加第三套 Secret。 +- `listContactMembers` 分离列表 token 与详情 token;默认详情 token 等于列表 token 以保持内部调用兼容,但正式聚合入口明确传入自建应用 token。 +- 指定 userid 与全量 userid 两条路径使用同一详情 token 合同;部门列表也使用自建应用 token。 +- `/user/get` 失败仍沿用最小 userid 回退,HR 信息可补足资料;真实 dry-run 必须以归一化后的必填字段和冲突检查作为写入门,不因 API `errcode=0` 自动放行。 + +## 真实职责写入门 + +- 先读取全量候选并在内存中按目标姓名匹配,只输出两个匹配计数、身份哈希是否不同、缺失字段数和计划动作数量。 +- 只有两个匹配计数均为 1、企微身份不同、均为 active、必填资料完整且手机号/邮箱不冲突时,才进入 apply。 +- 员工和企微绑定通过既有 HR master sync 服务创建或更新;随后通过既有角色写入语义分别设置 `employee + finance` 和 `employee + ceo`。 +- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、审计存在和付款业务零写入。 +- 任一门禁失败即停止,不进行部分角色写入;如员工同步已经成功而角色阶段失败,保留同步审计并立即报告,不用删除真实员工记录进行破坏性回滚。 + +## 企微本人敏感信息授权边界 + +- `/user/get` 和人事助手批量读取继续只负责 userid、姓名、部门等可读字段;不得把批量缺字段误判为企微完全不支持手机号和邮箱。 +- 2022-06-20 后新建自建应用的敏感字段走独立 OAuth 数据流:管理员在应用详情选择字段,成员以 `snsapi_privateinfo` 手工确认,`getuserinfo` 返回短时 `user_ticket`,服务端立即调用 `/cgi-bin/auth/getuserdetail`。 +- `user_ticket`、授权 code 和完整敏感响应不得写日志、任务证据或审计 extra;只保存业务所需的规范化手机号/邮箱及字段存在性审计。 +- 普通 `snsapi_base` 登录与敏感资料授权分开,避免每次登录引入手工授权;用户已批准未绑定的 active 可见成员通过独立入口自助创建仅授予 `employee` 的 OA 账号。 + +### OAuth 开通数据流 + +```text +GET /auth/wecom/login?mode=onboard + -> feature flag + state(mode=onboard) + -> authorize(scope=snsapi_privateinfo) + -> getuserinfo(code) -> userid + user_ticket + -> getuserdetail(user_ticket) -> mobile + email|biz_mail + -> user/get + department/list -> name + active + department + -> unique precheck -> transaction(employee + employee role + binding + audit) + -> authenticated session +``` + +- `mode=login|bind` 保持既有 `snsapi_base` 和行为;新增 `mode=onboard` 才使用 `snsapi_privateinfo`。 +- `getuserinfo` 只把 `user_ticket` 传给同一请求中的详情交换函数,诊断信息只记录字段是否存在。 +- 详情中的 userid 必须与身份接口 userid 一致;邮箱优先使用成员授权的个人邮箱,无个人邮箱时使用企业邮箱,二者都缺失则停止。 +- 联系人详情必须证明成员 active,并提供姓名、部门;手机号/邮箱与不同 OA 员工冲突时停止,不自动合并。 +- 已有绑定员工只补齐经授权的规范化资料且不改角色;新员工在单事务内创建 employee、`employee` 角色、企微绑定和脱敏审计。任何一步失败均不留下部分账号。 +- 自助开通不替代管理员职责分配;`finance`、`ceo` 仍沿用角色接口的 token version、会话撤销和审计合同。 + +## 部署目录统一设计约束 + +- 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。 +- 在停机切换前固定记录活动容器、数据挂载、环境文件摘要、端口、Caddy 路由和回滚镜像;不得打印环境变量内容。 +- 当前部署脚本固定探测宿主端口,而现场通过另一宿主端口提供 OA;脚本必须从已验证的 Compose/env 配置解析实际端口,不能硬编码。 +- 两套目录的 Compose 容器名和环境文件不同。切换前必须先选择唯一目录并证明其 DB/附件目标就是当前活动数据;不得让 Compose 创建第二套空数据库后误判部署成功。 +- 统一后应由同一目录负责 source sync、Compose、环境文件和运维 timer,并让 `/ready`、前端 marker 和容器环境同时暴露同一短构建标识。 +- 任一迁移状态、数据挂载、端口或公网构建标识不匹配时,立即切回旧容器和 Caddy 目标;不执行数据库 reset 或数据目录覆盖。 + +### 生产统一顺序 + +1. 使用已完成的成对异机备份作为灾难回滚点,并记录活动数据挂载、环境摘要、容器镜像和 Caddy 端口。 +2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。 +3. 停机后把非活动的既定部署目录改名保留,将活动数据目录和环境文件迁入既定目录;同一文件系统优先原子改名,不复制运行中的 PostgreSQL 文件。 +4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。 +5. 公网构建标识、数据计数、角色、企微入口和关键只读检查通过后更新运维 timer;失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。 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 a6433418..6fc7c372 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/implement.md @@ -12,12 +12,24 @@ - [x] 在单独授权后完成真实运行来源、职责分离、异机副本和 `/ready` 的只读核查,并如实记录 `NO_GO` 缺口。 - [x] 经明确授权完成提交发布和真实 DB+附件+manifest 成对异机备份,两端完整性复验通过。 - [ ] 经后续明确授权完成真实职责分配、部署/迁移和真实格式影子恢复后复验。 +- [x] 修复企微 HR 双 Secret 分工:通讯录 token 只列 userid,自建应用 token 读取详情、部门和 HR 信息。 +- [x] 补充单元测试并运行后端语法、聚焦测试、Prisma 与 Trellis 门禁。 +- [x] 在真实环境执行两名目标成员的脱敏 dry-run;两人均唯一、不同身份且 active,但批量链路均缺手机和邮箱,已按门禁停止真实写入。 +- [ ] 复用既有 HR 同步与角色语义写入 `employee + finance`、`employee + ceo`,复核会话失效、审计和 maker-checker,不部署、不迁移。 +- [x] 增加默认关闭的一次性企微敏感资料授权入口,保持普通登录静默;实现 `user_ticket -> getuserdetail`、active/字段/唯一性门禁和仅 `employee` 自助开通。 +- [x] 补齐 OAuth 路由、服务、配置契约和单元测试,证明票据/原始敏感响应不进入日志、Cookie、数据库或审计。 +- [ ] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。 +- [ ] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。 +- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。 +- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。 ## 推荐文件边界 - 复用并按需修改:`scripts/backup-db.sh`、`scripts/backup-storage.sh`、`scripts/restore-db.sh`、`scripts/restore-storage.sh`。 - 最多新增两个小脚本:一个成对备份入口、一个 QA-only 隔离演练入口;不创建通用备份框架。 - 更新现有 `deploy/trialrun/runbook.md`、`deploy/trialrun/checklist.md` 和 `.trellis/spec/hyb-oa/deployment-trialrun.md`,不新建常驻平行业务文档。 +- 优先修改 `server/src/routes/wecomAuth.js` 并抽取小型企微本人授权/员工开通服务;不把 HR 批同步服务改成通用身份平台。 +- 更新现有 `docs/企微API能力目录.md` 和 `.trellis/spec/hyb-oa/wecom-boundary.md`,记录批量读取与本人 OAuth 的稳定边界。 ## 验证命令 @@ -28,6 +40,18 @@ - `node scripts/qa/check-trellis-governance.mjs` - `node scripts/qa/check-sensitive-content.mjs` - `git diff --check` +- `cd server && node --check src/services/integrations/wecomHrMaster.js` +- `cd server && npm test -- --run tests/unit/wecomHrMaster.service.test.js` +- `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` +- `bash -n deploy/trialrun/deploy.sh` + +## 本轮本地验证证据(2026-07-26) + +- 后端全量 Vitest:162 个测试文件、1000 个测试通过;安全补修后企微聚焦回归为 3 个文件、30 个测试通过。 +- `prisma validate`、四个企微改动文件 `node --check`、部署环境契约和部署脚本语法均通过。 +- 本地实现没有开启 `WECOM_OAUTH_ONBOARDING_ENABLED`,没有部署、迁移、写真实员工/角色或修改企微后台权限。 ## 回滚点 @@ -35,3 +59,6 @@ - DB/附件安全门禁与现有操作冲突:先修 runbook 和调用方,不放宽默认门禁。 - 动态演练环境不可用:记录缺失依赖并停止,不使用真实数据替代。 - 任何命令解析到生产、远程或含真实数据的目标:立即停止并请求明确授权。 +- 真实 dry-run 任一目标非唯一、身份相同、字段缺失或存在唯一约束冲突:停止 apply,不尝试猜测身份。 +- 员工同步后角色写入失败:不删除真实员工或企微绑定;记录脱敏结果并停止,待修复后幂等重试角色阶段。 +- 现场 Compose 解析出第二套 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 d2069078..f4d68783 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/prd.md @@ -19,6 +19,10 @@ - 使用一次性 PostgreSQL 目标和临时附件目录完成合成数据恢复演练及负向测试。 - 更新现有试运行 runbook、checklist 和部署规约,明确影子恢复与真实恢复边界。 - 规划并保留真实运行 commit、访问边界、角色职责、异机副本及恢复后 `/ready` 的外部授权门;未授权前不执行这些动作。 +- 经用户逐项授权后,修正企微通讯录与人事助手的双 Secret 读取分工,并用脱敏 dry-run 唯一确认两名既有企微成员。 +- 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。 +- 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。 +- 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。 ## Requirements @@ -31,6 +35,15 @@ - 恢复演练至少证明:DB 标记数据恢复、附件内容哈希一致、损坏备份被拒绝、错误确认值被拒绝、非空附件目标被拒绝。 - 更新现有 runbook/checklist,使正常备份、影子演练、真实恢复三种场景不再共用含糊命令;保持现有本地存储默认,不启用 MinIO。 - 将真实内部实例核查列为后续同任务外部门禁:运行 commit/访问方式/TLS、真实职责分配、异机副本和恢复后 `/ready` 均需另行批准和脱敏留证。 +- 企微通讯录 Secret 只用于 `/user/list_id`;自建应用 Secret 用于 `/user/get`、`/department/list` 和获授权的人事助手 `/hr/get_staff_info`,不得回退到已废弃的人事助手 Secret。 +- 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。 +- 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。 +- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并实际同意手机号及邮箱或企业邮箱授权。 +- `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。 +- 自助开通按企微 userid、手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。 +- 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。 +- 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。 +- 部署健康检查必须从 Compose 解析实际宿主端口,并校验后端 `/ready`、前端 marker 与批准提交一致;应用已提交的 Prisma migrations 但禁止 reset 或改写历史。 ## Acceptance Criteria @@ -41,13 +54,21 @@ - [x] runbook/checklist 明确影子演练命令、生产停止条件、DB + 附件配对要求和异机副本要求。 - [x] 相关 shell 语法、QA 脚本、Trellis 治理、敏感扫描和 `git diff --check` 通过。 - [ ] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。 +- [x] 双 Secret 调用测试证明成员 ID 由通讯录 token 读取,成员详情、部门和 HR 资料由自建应用 token 读取。 +- [ ] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。 +- [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。 +- [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。 +- [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。 +- [ ] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。 +- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。 ## Out of Scope -- 不连接生产数据库,不读取真实附件,不执行远程恢复、部署或公网检查。 -- 不修改 Prisma schema、业务 API、权限模型、审批链或付款状态机。 +- 不读取真实附件正文,不执行生产恢复、数据库 reset、迁移历史改写或付款/报销业务写入。 +- 不修改 Prisma schema、公开业务 API、权限模型、审批链或付款状态机;仅修复既有 HR 同步服务的凭证分工,并通过既有角色模型写入获批职责。 - 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。 - 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。 +- 不发送企微消息、不创建企微审批模板、不把手机号/邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。 - 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。 ## Test Plan @@ -59,11 +80,20 @@ - `node scripts/qa/check-trellis-governance.mjs` - `node scripts/qa/check-sensitive-content.mjs` - `git diff --check` +- `cd server && node --check src/services/integrations/wecomHrMaster.js` +- `cd server && npm test -- --run tests/unit/wecomHrMaster.service.test.js` +- `cd server && npm run prisma:validate` +- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js` +- `bash -n deploy/trialrun/deploy.sh` +- `node deploy/trialrun/validate-env-contract.js` +- 获批部署窗口内执行迁移状态、Compose 数据挂载、`/ready`、前端 marker、Caddy 公网路由和回滚容器只读复验。 ## Key Decision 先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。 +企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料,不修改全局员工字段可空性;自助开通只授予 `employee`,业务角色继续由管理员审计写入。 + ## Local Evidence - 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。 @@ -77,9 +107,9 @@ - 真实角色只有一个活跃账号,财务和付款复核候选均为 0,不满足 maker-checker。 - 只读核查时的历史 DB/附件备份约 55 天未更新、无 manifest,且与运行数据位于同一设备;获批后已生成新的 DB+附件+manifest 完整批次并复制到独立主机,两端校验一致。 - 当前代码已在获批后以普通快进方式发布到 upstream,来源门禁为 `PASS`;尚未执行角色修改、部署、迁移或真实格式影子恢复,因此最后一项验收继续保持未勾选。 - -## Notes - -- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. -- Lightweight tasks can remain PRD-only. -- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. +- 当前 OA 是宿主机 Caddy、容器化 server/frontend、独立 PostgreSQL 容器和宿主附件挂载组成的混合部署;公网 OA 路由确实进入 server 容器。 +- 服务器存在两套无 Git 元数据的部署目录:运行容器来源与日常运维任务指向不同目录,Compose 和环境文件也不一致;运行代码与本机当前代码不一致且缺少构建标识。 +- 现场端口与当前部署脚本的固定健康检查端口不一致;未统一活动数据目录、容器名、端口和环境文件前,不得直接运行现有部署脚本。 +- 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。 +- 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。 +- 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。 diff --git a/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md b/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md index 08b5e373..e36be90b 100644 --- a/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md +++ b/.trellis/tasks/07-26-internal-ops-recovery-baseline/research/live-read-only-baseline.md @@ -81,3 +81,13 @@ - 运行主机没有宿主 Node.js,因此为成对备份入口增加 Python 3 manifest 等价回退;无 Node 路径已通过完整合成恢复演练,本地参考 RTO 仍为 6 秒。 - 运行中的 PostgreSQL 容器参数与部署目录环境文件存在漂移;本次只在运行容器参数与活动服务数据库连接相互匹配后执行备份,未修改环境文件。 - 本次未执行恢复、部署、迁移、角色修改、企微写入或业务写入;真实格式影子恢复和恢复后 `/ready` 仍无证据。 + +## 后续获批调查:部署拓扑与企微字段可用性 + +- 当前 OA 公网路由由宿主反向代理进入 server 容器;后端和前端构建位于同一应用镜像,数据库位于独立容器,附件由宿主目录挂载。 +- 运行容器来源目录与日常运维任务目录不同;两套目录都没有 Git 元数据,源文件相同但 Compose 与环境文件摘要不同。运行代码与本机当前代码不一致,`/ready` 无构建标识。 +- 仓库部署脚本的固定宿主健康检查端口与现场实际端口不一致。直接同步并执行现有 Compose 可能产生不同容器名或第二套数据目标,故未执行部署、重启、迁移或目录改写。 +- 使用修正后的双 Secret 读取链路做结构化探测:成员详情 6/6 成功、部门 6/6 可读,手机号/邮箱/企业邮箱均 0/6 可读;HR 详情 6/6 返回成功但 `staff_info` 为 0/6。该结果只证明批量读取链路拿不到敏感字段。 +- 企微官方文档复核:2022-06-20 后新建的自建应用不再通过 `/user/get` 返回手机、邮箱和企业邮箱;管理员需在应用详情选择敏感字段,成员使用 `snsapi_privateinfo` 手工授权,`/cgi-bin/auth/getuserinfo` 才返回 30 分钟有效的 `user_ticket`,随后 `/cgi-bin/auth/getuserdetail` 可返回成员同意的手机号、邮箱和企业邮箱。 +- 当前 OA `server/src/routes/wecomAuth.js` 固定使用 `snsapi_base`,只调用 `getuserinfo` 获取 userid,不调用 `getuserdetail`;因此现状是项目尚未接入官方敏感信息授权链路,而不是企微没有该能力。 +- 两名目标成员的脱敏结果为匹配计数 `[1,1]`、身份不同、均 active、数据库匹配 `[0,0]`、计划动作均为 create;两人均缺 `phone` 与 `email`,因此没有员工、角色、会话、付款或其他业务写入。 diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index 91a131d2..1f74318a 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -23,6 +23,8 @@ APP_ORIGIN=https://oa.example.com APP_TIMEZONE=Asia/Shanghai HOST=0.0.0.0 PORT=3000 +# 宿主机回环端口;容器内仍固定监听 PORT,Caddy 必须指向此端口。 +APP_HOST_PORT=3000 TRUST_PROXY_HOPS=1 COOKIE_SECURE=true OPENAPI_DOCS_ENABLED=false @@ -76,6 +78,7 @@ WECOM_CORP_ID= WECOM_AGENT_ID= WECOM_AGENT_SECRET= WECOM_OAUTH_ENABLED=false +WECOM_OAUTH_ONBOARDING_ENABLED=false WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback WECOM_ALLOWED_RETURN_HOSTS=oa.example.com WECOM_CHECKIN_SYNC_ENABLED=false diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md index a3d1c9e2..238417e3 100644 --- a/deploy/trialrun/checklist.md +++ b/deploy/trialrun/checklist.md @@ -14,7 +14,7 @@ - [ ] 已确认 `deploy/trialrun/.env` 作为部署事实源,新增环境变量优先补到该文件 - [ ] 已尽量使用无引号 env 值;历史带引号值当前兼容,但建议逐步清理 - [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile` -- [ ] 已确认 Caddy 反代到 `127.0.0.1:3000`,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF +- [ ] 已确认 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF - [ ] 已执行 `systemctl reload caddy` - [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh` - [ ] 已通过 `https:///ready` 或首页登录完成最小探活 @@ -22,7 +22,7 @@ - [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据 - [ ] 不导入历史审批单、通知历史、审计日志、聊天记录和涉密正文附件 - [ ] 已确认远端部署目录不是 git 工作树,发布优先走 Git archive 模式的 `bash deploy/trialrun/deploy.sh` -- [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:3000` +- [ ] 已确认 OA 前端静态资源由 server 容器内 `/app/client/dist` 提供,Caddy 只反代到 `127.0.0.1:` ## A. 发布前(Pre-flight) - [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"` @@ -44,7 +44,7 @@ - [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke - [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=` 与 `VITE_BUILD_SHA=`;未使用 `unknown` - [ ] 探活: - - [ ] `curl -fsS http://127.0.0.1:3000/ready` + - [ ] 已用 `docker-compose ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT` - [ ] Runtime smoke: - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js` - [ ] 若 runtime smoke 报 `announcements_v1` 或 `global_search_v1` 为 `force_off`,已记录为发布阻塞,不忽略通过 @@ -77,7 +77,7 @@ - [ ] 回到上个版本:`git checkout ` - [ ] 重启并探活: - [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server` - - [ ] `curl -fsS http://127.0.0.1:3000/ready` + - [ ] 已重新用 `docker-compose ... port server 3000` 解析实际宿主端口并执行 `/ready` - [ ] 若仅为 OCR 应急降级,已把远端 `.env` 的 `OCR_PROVIDER` 临时切回 `mock`,重启 server,并在 Issue/发布记录中写明原因、影响范围和恢复时间 diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index bde8813a..a8d82f9c 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -193,9 +193,15 @@ if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma mig fi echo ">>> 4.7 等待 /ready 并核对构建标识" +server_port_output="$("${compose_cmd[@]}" port server 3000)" +server_host_port="${server_port_output##*:}" +if [[ ! "$server_host_port" =~ ^[0-9]+$ ]]; then + echo "[ERROR] 无法从 Compose 解析 server 宿主端口" >&2 + exit 1 +fi ready_ok=0 for i in $(seq 1 60); do - if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then + if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then if READY_PAYLOAD="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e ' const payload = JSON.parse(process.env.READY_PAYLOAD || "{}"); if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1); @@ -263,4 +269,4 @@ echo ">>> 10. 部署完成" echo " 来源提交: ${SOURCE_COMMIT}" echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke + 公网前端资源文案扫描" echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke" -echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 127.0.0.1:3000" +echo " 请确认 Caddy 或等效反向代理已把 OA 域名反代到 Compose 暴露的回环端口" diff --git a/deploy/trialrun/docker-compose.yml b/deploy/trialrun/docker-compose.yml index bf459d2d..22af7510 100644 --- a/deploy/trialrun/docker-compose.yml +++ b/deploy/trialrun/docker-compose.yml @@ -69,7 +69,7 @@ services: NODE_ENV: production BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit} ports: - - "127.0.0.1:3000:3000" + - "127.0.0.1:${APP_HOST_PORT:-3000}:3000" volumes: - ./data/storage:${STORAGE_DIR} diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md index 582fe96e..015508b7 100644 --- a/deploy/trialrun/runbook.md +++ b/deploy/trialrun/runbook.md @@ -6,7 +6,7 @@ - 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。 - OA 新增独立二级域名,例如 `oa.hyinda.com`。 - OA 部署目录由受控环境中的 `REMOTE_DIR` 指定,不在仓库固化机器路径。 -- OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。 +- OA 推荐访问方式:`Caddy(443) -> 127.0.0.1: -> docker-compose(server:3000)`。 - Caddy 必须覆盖客户端传入的 `X-Forwarded-For`。OA 站点的 `reverse_proxy` 中应设置 `header_up X-Forwarded-For {remote_host}`,不得保留不可信客户端链。 - 首次初始化时: 1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh` @@ -16,7 +16,7 @@ ## 0B. 历史部署基线(基础设施信息已脱敏) - SSH 目标、部署账号和机器地址只保存在受控运维配置中,通过 `DEPLOY_SERVER` 显式传入。 -- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 已反代到 `127.0.0.1:3000` +- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。 - 部署目录通过 `REMOTE_DIR` 显式传入。 - 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器 - 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准 @@ -45,6 +45,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env - `deploy/trialrun/.env` 现在是 trialrun 部署事实源,`server / worker / init` 统一通过 `env_file` 注入。 - `BUILD_SHA` / `VITE_BUILD_SHA` 由发布入口按已批准提交临时注入,不写入长期 `.env`;前者供后端运行时核对,后者只允许 12 位短 SHA 进入公网 bundle。 - 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。 +- `PORT=3000` 是容器内端口;`APP_HOST_PORT` 是宿主回环端口。发布脚本通过 Compose 动态解析后者,Caddy 必须指向相同值。 - 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。 - 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。 - **EXTERNAL_GATED**:涉及认证安全迁移时,发布或回滚前必须由操作人先在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再执行“新镜像配置预检 -> additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。 @@ -102,9 +103,11 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ### 1.4 验收 ```bash -curl -fsS http://127.0.0.1:3000/health -curl -fsS http://127.0.0.1:3000/ready -curl -fsS http://127.0.0.1:3000/metrics | head +SERVER_HOST_PORT="$(docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)" +SERVER_HOST_PORT="${SERVER_HOST_PORT##*:}" +curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/health" +curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready" +curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/metrics" | head docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js ``` @@ -119,7 +122,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ - 2026-05-05 公网复核显示:`OCR_PROVIDER=tencent` 可用,真实发票上传后进入 `available`,provider 为 `tencent`;后续发布不能再把 OCR 回退到 mock 而不记录原因。 浏览器访问: -- `http://127.0.0.1:3000`(单域托管前端) +- `docker-compose ... port server 3000` 返回的宿主回环端口(单域托管前端)。 ### 1.4.1 腾讯 OCR 试运行核查 远端 `.env` 是 OCR 配置事实源,禁止提交或复制密钥明文。只做以下脱敏检查: @@ -273,13 +276,14 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/ ``` ### 2.2 前端部署一致性排障 -当前 `oa.hyinda.com` 由 Caddy 反代到 `127.0.0.1:3000`,前端静态资源由 server 容器内的 `/app/client/dist` 提供,不是 `/etc/caddy/Caddyfile` 里的独立静态目录。 +`oa.hyinda.com` 由 Caddy 反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口,前端静态资源由 server 容器内的 `/app/client/dist` 提供,不是 `/etc/caddy/Caddyfile` 里的独立静态目录。 如果出现“本地源码已有功能,但公网仍显示旧 UI”,先按以下顺序核查: ```bash -# 1) 确认 Caddy 仍是反代到 3000 +# 1) 确认 Caddy 指向 Compose 实际发布的宿主回环端口 sudo sed -n '/oa.hyinda.com/,/}/p' /etc/caddy/Caddyfile +docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000 # 2) 比对公网首页引用的 asset hash curl -fsS https://oa.hyinda.com/ | grep -o '/assets/[^" ]*' | head @@ -295,7 +299,8 @@ docker exec hyb-oa-trialrun-server sh -lc \ cd /opt/hyb-oa-trialrun docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm -T init 65535 )); then + echo "[ERROR] APP_HOST_PORT 必须是 1-65535 的端口号" >&2 + exit 1 +fi + need_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "[ERROR] 缺少命令: $1" >&2 @@ -63,7 +69,7 @@ mkdir -p "${REMOTE_DIR}/deploy/trialrun/data/minio" chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${REMOTE_DIR}" echo ">>> 4. 初始化完成,后续手工操作:" -echo " 1) 在 ${CADDY_CONFIG_PATH} 增加站点块:${OA_HOST} { reverse_proxy 127.0.0.1:3000 }" +echo " 1) 在 ${CADDY_CONFIG_PATH} 增加站点块:${OA_HOST} { reverse_proxy 127.0.0.1:${APP_HOST_PORT} }" echo " 2) 执行 systemctl reload caddy" echo " 3) 在 ${REMOTE_DIR}/deploy/trialrun/.env 写入 OA 环境变量" echo " 4) 切换到 deploy 用户后,从本地执行 deploy/trialrun/deploy.sh" diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 083678d3..f00faf4e 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -185,8 +185,8 @@ for (const serviceName of requiredServices) { } const serverBlock = extractServiceBlock(composeContent, 'server'); -if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:3000:3000["']\s*$/m.test(serverBlock)) { - errors.push('服务 server 的 3000 端口必须只绑定宿主回环地址'); +if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}:3000["']\s*$/m.test(serverBlock)) { + errors.push('服务 server 必须通过 APP_HOST_PORT 将容器 3000 端口只绑定到宿主回环地址'); } for (const { filePath, content } of nginxContents) { diff --git a/docs/企微API能力目录.md b/docs/企微API能力目录.md index 5cee49f8..26938d4b 100644 --- a/docs/企微API能力目录.md +++ b/docs/企微API能力目录.md @@ -4,7 +4,7 @@ **分类**:集成事实源 / API 能力目录。 **适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WADM1 / WARCH1 / WARCH2 / WROAD2 / WARCH3 / WCHECK1 / WEXT1 / WOPS1 / WROAD2-CLOSEOUT / WROAD2-BIZ1 / WAPPR1 / WAUTO1 / WEXT1LD / WFULLSMOKE1 / WROUTE2 / WRULE1 / WREAL1-PREP 之后。 **关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WRULE1,WREAL1P`。 -**最近核对**:2026-05-20。 +**最近核对**:2026-07-26(Trellis task `07-26-internal-ops-recovery-baseline`)。 **不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。 ## 1. 固定规则 @@ -25,8 +25,8 @@ | Secret | 当前标准用途 | 禁止或限制 | | --- | --- | --- | -| `WECOM_CONTACT_SECRET` | 通讯录专用:成员 ID 清单、成员详情、部门、创建/更新/禁用成员。 | 不用于工作台消息、人事助手、打卡或审批;运行时代码不得 fallback 到 `WECOM_AGENT_SECRET`。 | -| `WECOM_AGENT_SECRET` | 工作台自建应用和多数开放 API:OAuth/免登、应用消息、人事助手/花名册、打卡、审批读取。 | 不作为通讯录读取的默认 fallback;缺权限时记录阻断,不换 Secret 试错。 | +| `WECOM_CONTACT_SECRET` | 当前只用于 `/user/list_id` 获取成员 userid 清单。 | 不用于 `/user/get`、部门、工作台消息、人事助手、打卡或审批;运行时代码不得 fallback 到 `WECOM_AGENT_SECRET`。 | +| `WECOM_AGENT_SECRET` | 工作台自建应用和多数开放 API:OAuth/免登、`/user/get`、`/department/list`、应用消息、人事助手/花名册、打卡、审批读取。 | 不用于 `/user/list_id`;缺权限时记录阻断,不换 Secret 试错。 | | `WECOM_CHECKIN_SECRET` | 仅作为专用打卡应用的显式 override;为空时使用 `WECOM_AGENT_SECRET`。 | 不要求为了打卡重复维护 Secret;只有后台单独建打卡应用时才配置。 | | `WECOM_HR_ASSISTANT_SECRET` | 当前不作为标准配置,历史兼容项。 | 默认不参与人事助手读取;后续若确需启用,必须先更新本文和 Issue CSV。 | @@ -40,13 +40,14 @@ | 企业微信回调 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/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 分开管理。 | | 成员 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_CONTACT_SECRET` | `OPTIONAL_ENRICHMENT` | 用于补齐 userid 对应的通讯录详情;若 `list_id` 与人事助手花名册可读,详情权限不足不阻断 WOUA,只降低可富化字段完整度。 | +| 读取成员 | [读取成员](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` | 仅作历史参考,当前全量读取不依赖它。 | | 获取部门成员详情 | [获取部门成员详情](https://developer.work.weixin.qq.com/document/path/90201) | `/cgi-bin/user/list` | `GET` | `WECOM_CONTACT_SECRET` | `LEGACY_NOT_DEFAULT` | 当前部署曾返回权限错误,不再作为 WOUA/WHMS 全量入口。 | -| 获取部门列表 | [获取部门列表](https://developer.work.weixin.qq.com/document/path/90208) | `/cgi-bin/department/list` | `GET` | `WECOM_CONTACT_SECRET` | `OPTIONAL_ENRICHMENT` | 只用于部门名富化;失败不得阻断 userid 清单读取和只读对账。 | +| 获取部门列表 | [获取部门列表](https://developer.work.weixin.qq.com/document/path/90208) | `/cgi-bin/department/list` | `GET` | `WECOM_AGENT_SECRET` | `OPTIONAL_ENRICHMENT` | 用自建应用 token 做部门名富化;失败不得把 userid 清单误判为空,也不得改用通讯录 token 试错。 | | 创建成员 | [创建成员](https://developer.work.weixin.qq.com/document/path/90195) | `/cgi-bin/user/create` | `POST` | `WECOM_CONTACT_SECRET` | `DONE_LIVE_VERIFIED` | 已由 HRWD/HCLT 验证公网 create;仅作为管理员修复/迁移能力,不作为默认入职主流程。 | | 更新成员 | [更新成员](https://developer.work.weixin.qq.com/document/path/90197) | `/cgi-bin/user/update` | `POST` | `WECOM_CONTACT_SECRET` | `DONE_LIVE_VERIFIED` | 仅在明确合同下做受控更新;不得静默覆盖企微资料。 | | 删除/禁用成员 | [删除成员](https://developer.work.weixin.qq.com/document/path/90198) | `/cgi-bin/user/delete` 或更新启用状态 | `GET/POST` | `WECOM_CONTACT_SECRET` | `DONE_LIVE_VERIFIED_FOR_DISABLE` | 本项目离职默认禁用成员,不删除成员;历史业务和绑定审计保留。 | @@ -143,6 +144,6 @@ WADM1/WARCH1 将已验证的审批能力固定为“可读可留存”的实现 ## 8. 后续使用要求 -- 新增企微能力前,先在本文补一行能力记录,再写 Issue CSV。 +- 新增企微能力前,先在本文补一行能力记录,并从当前 Trellis task 组织需求、实现和验证;历史 Issue CSV 仅用于追溯或明确要求的外部导出。 - POC 输出统一使用 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API`、`SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 - 公网验证报告只允许输出脱敏 userid 前缀、计数、错误码、token source 和结论,不得输出 Secret、token、完整 userid、手机号、邮箱或原始 payload。 diff --git a/server/src/config.js b/server/src/config.js index 6e70189f..72e5c000 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -183,6 +183,7 @@ function getConfig() { wecomHrMasterSyncEnabled: process.env.WECOM_HR_MASTER_SYNC_ENABLED === "true", wecomHrAssistantSecret: process.env.WECOM_HR_ASSISTANT_SECRET ?? "", wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true", + wecomOauthOnboardingEnabled: process.env.WECOM_OAUTH_ONBOARDING_ENABLED === "true", wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "", wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "", wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "", diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js index 80443883..dde0fc3d 100644 --- a/server/src/routes/wecomAuth.js +++ b/server/src/routes/wecomAuth.js @@ -6,6 +6,11 @@ const { buildAuditLogData } = require("../utils/audit"); const { randomRefreshToken, sha256Hex } = require("../utils/crypto"); const { signPayload, verifyToken } = require("../utils/signedUrl"); const { sendData } = require("../utils/reply"); +const { + WecomOAuthOnboardingError, + fetchWecomOAuthOnboardingProfile, + provisionWecomOAuthEmployee, +} = require("../services/integrations/wecomOAuthOnboarding"); const BIND_COOKIE_NAME = "wecom_bind"; const PENDING_LOGIN_BIND_COOKIE_NAME = "wecom_login_bind"; @@ -15,7 +20,7 @@ const DEFAULT_RETURN_TO = "/mobile"; const DISABLED_STATUS_REASONS = new Set(["wecom_disabled", "entry_disabled", "oauth_disabled"]); const QUERY_SCHEMA = z.object({ returnTo: z.string().optional(), - mode: z.enum(["login", "bind"]).optional(), + mode: z.enum(["login", "bind", "onboard"]).optional(), deviceFingerprint: z.string().trim().min(1).max(128).optional(), }); const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional(); @@ -159,6 +164,7 @@ function resolveWecomEntryConfig(config) { && config.wecomRedirectUri, ); const entryEnabled = Boolean(bindEnabled && config.wecomEntryEnabled); + const onboardingEnabled = Boolean(bindEnabled && config.wecomOauthOnboardingEnabled); const hasDisabledReason = statusReasons.some((reason) => DISABLED_STATUS_REASONS.has(reason)); return { @@ -169,6 +175,8 @@ function resolveWecomEntryConfig(config) { agentId: String(config.wecomAgentId ?? ""), loginUrl: entryEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "login") : null, bindEnabled, + onboardingEnabled, + onboardingUrl: onboardingEnabled ? buildWecomLoginUrl(DEFAULT_RETURN_TO, "onboard") : null, defaultReturnTo: DEFAULT_RETURN_TO, status: entryEnabled ? "ready" : hasDisabledReason ? "disabled" : "misconfigured", statusReasons, @@ -199,10 +207,17 @@ function assertWecomEntryReady(config) { return entryConfig; } +function assertWecomOnboardingReady(config) { + assertWecomOauthReady(config); + if (!config.wecomOauthOnboardingEnabled) { + throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 }); + } +} + function buildWecomLoginUrl(returnTo = DEFAULT_RETURN_TO, mode = "login") { const params = new URLSearchParams({ returnTo }); - if (mode === "bind") { - params.set("mode", "bind"); + if (mode === "bind" || mode === "onboard") { + params.set("mode", mode); } return `/api/auth/wecom/login?${params.toString()}`; } @@ -219,14 +234,24 @@ async function getWecomAccessToken(config) { const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken"); url.searchParams.set("corpid", config.wecomCorpId); url.searchParams.set("corpsecret", config.wecomAgentSecret); - const response = await fetch(url); + let response; + try { + response = await fetch(url); + } catch { + throw new AppError({ + code: "AUTH_003", + message: "企业微信认证失败", + statusCode: 401, + details: { wecom: "token_exchange_failed" }, + }); + } const payload = await response.json().catch(() => null); if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, - details: { wecom: "token_exchange_failed", payload }, + details: { wecom: "token_exchange_failed" }, }); } return String(payload.access_token); @@ -237,14 +262,24 @@ async function exchangeWecomCode(config, code) { const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"); url.searchParams.set("access_token", accessToken); url.searchParams.set("code", code); - const response = await fetch(url); + let response; + try { + response = await fetch(url); + } catch { + throw new AppError({ + code: "AUTH_003", + message: "企业微信认证失败", + statusCode: 401, + details: { wecom: "userinfo_exchange_failed" }, + }); + } const payload = await response.json().catch(() => null); if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) { throw new AppError({ code: "AUTH_003", message: "企业微信认证失败", statusCode: 401, - details: { wecom: "userinfo_exchange_failed", payload }, + details: { wecom: "userinfo_exchange_failed" }, }); } const payloadKeys = payload && typeof payload === "object" ? Object.keys(payload).sort() : []; @@ -261,6 +296,8 @@ async function exchangeWecomCode(config, code) { externalUserId: pickCandidate(payload.UserId, payload.userid), externalOpenUserId: pickCandidate(payload.OpenId, payload.openid), deviceId: pickCandidate(payload.DeviceId, payload.deviceid), + userTicket: pickCandidate(payload.user_ticket), + accessToken, diagnostics: { errcode: Number(payload.errcode ?? 0), errmsg: typeof payload.errmsg === "string" ? payload.errmsg : null, @@ -280,9 +317,9 @@ async function exchangeWecomCode(config, code) { }; } -function buildWecomAuthorizeUrl(config, state) { +function buildWecomAuthorizeUrl(config, state, mode = "login") { const redirectUri = encodeURIComponent(config.wecomRedirectUri); - const scope = "snsapi_base"; + const scope = mode === "onboard" ? "snsapi_privateinfo" : "snsapi_base"; return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`; } @@ -364,6 +401,8 @@ async function wecomAuthRoutes(fastify) { reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config)); throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); } + } else if (mode === "onboard") { + assertWecomOnboardingReady(fastify.config); } else { assertWecomEntryReady(fastify.config); } @@ -376,7 +415,7 @@ async function wecomAuthRoutes(fastify) { mode, deviceFingerprint, }, fastify.config.jwtSecret); - return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state)); + return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state, mode)); }); fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => { @@ -427,7 +466,7 @@ async function wecomAuthRoutes(fastify) { if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) { throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 }); } - const mode = ctx.mode === "bind" ? "bind" : "login"; + const mode = ctx.mode === "bind" ? "bind" : ctx.mode === "onboard" ? "onboard" : "login"; const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request); const profile = await exchangeWecomCode(fastify.config, code); if (!profile.externalUserId) { @@ -459,6 +498,48 @@ async function wecomAuthRoutes(fastify) { const origin = resolvePrimaryAppOrigin(fastify.config, request); + if (mode === "onboard") { + assertWecomOnboardingReady(fastify.config); + if (!profile.userTicket) { + return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=consent_required`); + } + try { + const onboardingProfile = await fetchWecomOAuthOnboardingProfile({ + accessToken: profile.accessToken, + externalUserId: profile.externalUserId, + userTicket: profile.userTicket, + }); + const employee = await provisionWecomOAuthEmployee({ + prisma: fastify.prisma, + request, + profile: onboardingProfile, + externalOpenUserId: profile.externalOpenUserId, + }); + const deviceFingerprint = resolveOAuthDeviceFingerprint(ctx.deviceFingerprint); + const session = await createAuthenticatedSession({ + prisma: fastify.prisma, + request, + config: fastify.config, + employeeId: employee.id, + tokenVersion: employee.token_version, + deviceFingerprint, + auditAction: "AUTH_WECOM_ONBOARDING_LOGIN_SUCCESS", + extra: { created: employee.created }, + }); + reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config)); + reply.setCookie("has_session", "1", sessionHintCookieOptions(fastify.config)); + reply.setCookie(DEVICE_FINGERPRINT_COOKIE_NAME, deviceFingerprint, deviceFingerprintCookieOptions(fastify.config)); + return reply.redirect(`${origin}${returnTo}`); + } catch (error) { + if (error instanceof WecomOAuthOnboardingError) { + return reply.redirect( + `${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=${encodeURIComponent(error.code)}`, + ); + } + throw error; + } + } + if (mode === "bind") { const bindCookie = request.cookies?.[BIND_COOKIE_NAME]; const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret); diff --git a/server/src/services/integrations/wecomHrMaster.js b/server/src/services/integrations/wecomHrMaster.js index 87867944..f69fb216 100644 --- a/server/src/services/integrations/wecomHrMaster.js +++ b/server/src/services/integrations/wecomHrMaster.js @@ -327,7 +327,7 @@ async function fetchDepartmentMap({ accessToken, fetchImpl, timeoutMs }) { return normalizeDepartmentMap(result.payload); } -async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs }) { +async function listContactMembers({ accessToken, detailAccessToken = accessToken, limit, fetchImpl, timeoutMs }) { const membersByUserid = new Map(); let cursor = ""; @@ -363,7 +363,7 @@ async function listContactMembers({ accessToken, limit, fetchImpl, timeoutMs }) const members = Array.from(membersByUserid.values()).slice(0, limit); const contacts = await Promise.all(members.map(async (member) => { const detail = await fetchOptionalContactMember({ - accessToken, + accessToken: detailAccessToken, userid: member.userid, fetchImpl, timeoutMs, @@ -477,21 +477,22 @@ function normalizeWecomHrProfile({ contact = {}, staff = {}, departmentMap = new return profile; } -async function fetchContactProfilesInput({ config, token, userids, limit, fetchImpl, timeoutMs }) { +async function fetchContactProfilesInput({ config, token, detailToken = token, userids, limit, fetchImpl, timeoutMs }) { const departmentMap = await fetchOptionalDepartmentMap({ - accessToken: token.accessToken, + accessToken: detailToken.accessToken, fetchImpl, timeoutMs, }); const contacts = userids.length ? await Promise.all(userids.map((userid) => fetchOptionalContactMember({ - accessToken: token.accessToken, + accessToken: detailToken.accessToken, userid, fetchImpl, timeoutMs, }))) : await listContactMembers({ accessToken: token.accessToken, + detailAccessToken: detailToken.accessToken, limit, fetchImpl, timeoutMs, @@ -526,6 +527,7 @@ async function fetchWecomHrProfiles({ config, userids, limit, fetchImpl = fetch const contactInput = await fetchContactProfilesInput({ config, token: contactToken, + detailToken: hrToken, userids, limit: readLimit, fetchImpl, diff --git a/server/src/services/integrations/wecomOAuthOnboarding.js b/server/src/services/integrations/wecomOAuthOnboarding.js new file mode 100644 index 00000000..8aea2200 --- /dev/null +++ b/server/src/services/integrations/wecomOAuthOnboarding.js @@ -0,0 +1,316 @@ +const crypto = require("node:crypto"); +const { z } = require("zod"); + +const { buildAuditLogData } = require("../../utils/audit"); +const { hashPassword } = require("../../utils/crypto"); + +const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; +const PRIVATE_EMAIL_SCHEMA = z.string().trim().email().max(200).transform((value) => value.toLowerCase()); +const PRIVATE_PHONE_SCHEMA = z.string().trim().min(6).max(30); + +class WecomOAuthOnboardingError extends Error { + constructor(code, message) { + super(message); + this.name = "WecomOAuthOnboardingError"; + this.code = code; + } +} + +function requiredString(value, maxLength) { + if (typeof value !== "string") return ""; + return value.trim().slice(0, maxLength); +} + +function normalizeEmail(value) { + const parsed = PRIVATE_EMAIL_SCHEMA.safeParse(value); + return parsed.success ? parsed.data : ""; +} + +function normalizePhone(value) { + const parsed = PRIVATE_PHONE_SCHEMA.safeParse(value); + return parsed.success ? parsed.data : ""; +} + +async function fetchWecomJson(pathName, accessToken, options = {}) { + const url = new URL(`${WECOM_API_BASE}${pathName}`); + url.searchParams.set("access_token", accessToken); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value != null && value !== "") url.searchParams.set(key, String(value)); + } + + let response; + try { + response = await (options.fetchImpl ?? fetch)(url, { + method: options.body ? "POST" : "GET", + headers: options.body ? { "Content-Type": "application/json" } : undefined, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + } catch { + throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed"); + } + const payload = await response.json().catch(() => null); + if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) { + throw new WecomOAuthOnboardingError("wecom_api_failed", "WeCom onboarding API failed"); + } + return payload; +} + +function normalizeDepartmentMap(payload) { + const map = new Map(); + for (const item of Array.isArray(payload?.department) ? payload.department : []) { + const externalId = requiredString(String(item?.id ?? item?.department_id ?? ""), 64); + const name = requiredString(item?.name, 100); + if (externalId && name) map.set(externalId, name); + } + return map; +} + +async function fetchWecomOAuthOnboardingProfile({ + accessToken, + externalUserId, + userTicket, + fetchImpl = fetch, +}) { + const userid = requiredString(externalUserId, 128); + const ticket = requiredString(userTicket, 512); + if (!userid || !ticket) { + throw new WecomOAuthOnboardingError("consent_required", "WeCom private information consent is required"); + } + + const [privateDetail, contact, departmentPayload] = await Promise.all([ + fetchWecomJson("/cgi-bin/auth/getuserdetail", accessToken, { + body: { user_ticket: ticket }, + fetchImpl, + }), + fetchWecomJson("/cgi-bin/user/get", accessToken, { + query: { userid }, + fetchImpl, + }), + fetchWecomJson("/cgi-bin/department/list", accessToken, { fetchImpl }), + ]); + + const detailUserid = requiredString(privateDetail.userid ?? privateDetail.UserId, 128); + if (!detailUserid || detailUserid !== userid) { + throw new WecomOAuthOnboardingError("identity_mismatch", "WeCom onboarding identity mismatch"); + } + + const status = Number(contact.status ?? contact.enable ?? 0); + if (status !== 1) { + throw new WecomOAuthOnboardingError("member_inactive", "WeCom member is not active"); + } + + const departmentExternalId = requiredString( + String(contact.main_department ?? contact.department?.[0] ?? ""), + 64, + ); + const departmentName = normalizeDepartmentMap(departmentPayload).get(departmentExternalId) ?? ""; + const profile = { + externalUserId: userid, + name: requiredString(contact.name, 100), + phone: normalizePhone(privateDetail.mobile), + email: normalizeEmail(privateDetail.email) || normalizeEmail(privateDetail.biz_mail), + departmentExternalId, + departmentName, + title: requiredString(contact.position, 100) || "员工", + }; + + const missing = []; + for (const field of ["name", "phone", "email", "departmentExternalId", "departmentName"]) { + if (!profile[field]) missing.push(field); + } + if (missing.length) { + throw new WecomOAuthOnboardingError("missing_required_field", "WeCom onboarding fields are incomplete"); + } + return profile; +} + +async function resolveDepartment(tx, profile, actorId) { + const binding = await tx.integrationBinding.findFirst({ + where: { + provider: "wecom", + entity_type: "department", + external_id: profile.departmentExternalId, + }, + }); + if (binding) { + const department = await tx.department.findUnique({ + where: { id: binding.local_id }, + select: { id: true, archived_at: true }, + }); + if (!department || department.archived_at) { + throw new WecomOAuthOnboardingError("department_invalid", "WeCom department binding is unavailable"); + } + return department.id; + } + + const matches = await tx.department.findMany({ + where: { name: profile.departmentName, archived_at: null }, + select: { id: true }, + take: 2, + }); + if (matches.length > 1) { + throw new WecomOAuthOnboardingError("department_ambiguous", "WeCom department matched multiple OA departments"); + } + + const departmentId = matches[0]?.id ?? (await tx.department.create({ + data: { + name: profile.departmentName, + created_by: actorId, + updated_by: actorId, + }, + select: { id: true }, + })).id; + + await tx.integrationBinding.create({ + data: { + provider: "wecom", + entity_type: "department", + local_id: departmentId, + external_id: profile.departmentExternalId, + external_code: profile.departmentName, + last_synced_at: new Date(), + created_by: actorId, + updated_by: actorId, + }, + }); + return departmentId; +} + +async function resolveEmployee(tx, profile) { + const binding = await tx.wecomIdentityBinding.findUnique({ + where: { external_user_id: profile.externalUserId }, + select: { + employee: { + select: { id: true, status: true }, + }, + }, + }); + const contactMatches = await tx.employee.findMany({ + where: { OR: [{ phone: profile.phone }, { email: profile.email }] }, + select: { + id: true, + status: true, + wecom_identity_binding: { select: { external_user_id: true } }, + }, + take: 3, + }); + + const matches = new Map(); + if (binding?.employee) matches.set(binding.employee.id, binding.employee); + for (const employee of contactMatches) matches.set(employee.id, employee); + if (matches.size > 1) { + throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding matched multiple OA employees"); + } + const employee = [...matches.values()][0] ?? null; + if (employee && employee.status !== "active") { + throw new WecomOAuthOnboardingError("employee_inactive", "Matched OA employee is inactive"); + } + const existingExternalUserId = requiredString(employee?.wecom_identity_binding?.external_user_id, 128); + if (existingExternalUserId && existingExternalUserId.toLowerCase() !== profile.externalUserId.toLowerCase()) { + throw new WecomOAuthOnboardingError("employee_conflict", "Matched OA employee is bound to another WeCom member"); + } + return employee; +} + +async function provisionWecomOAuthEmployee({ + prisma, + request, + profile, + externalOpenUserId = null, +}) { + try { + return await prisma.$transaction(async (tx) => { + const existing = await resolveEmployee(tx, profile); + const actorId = existing?.id ?? null; + const departmentId = await resolveDepartment(tx, profile, actorId); + const now = new Date(); + const employee = existing + ? await tx.employee.update({ + where: { id: existing.id }, + data: { + name: profile.name, + phone: profile.phone, + email: profile.email, + department_id: departmentId, + title: profile.title, + updated_at: now, + updated_by: existing.id, + }, + select: { id: true, token_version: true }, + }) + : await tx.employee.create({ + data: { + name: profile.name, + phone: profile.phone, + email: profile.email, + department_id: departmentId, + title: profile.title, + hire_date: now, + status: "active", + employment_status: "active", + bank_account_encrypted: "", + bank_name: "", + password_hash: await hashPassword(`WecomOnboardA1${crypto.randomBytes(18).toString("hex")}`), + must_change_password: true, + created_at: now, + updated_at: now, + }, + select: { id: true, token_version: true }, + }); + + if (!existing) { + await tx.employeeRole.create({ + data: { + employee_id: employee.id, + role_code: "employee", + created_at: now, + created_by: employee.id, + }, + }); + } + + await tx.wecomIdentityBinding.upsert({ + where: { employee_id: employee.id }, + update: { + external_user_id: profile.externalUserId, + external_open_user_id: externalOpenUserId, + updated_by: employee.id, + }, + create: { + employee_id: employee.id, + external_user_id: profile.externalUserId, + external_open_user_id: externalOpenUserId, + created_by: employee.id, + updated_by: employee.id, + }, + }); + + await tx.auditLog.create({ + data: buildAuditLogData(request, { + actorId: employee.id, + action: existing ? "AUTH_WECOM_PROFILE_COMPLETED" : "AUTH_WECOM_SELF_PROVISIONED", + targetType: "employee", + targetId: employee.id, + extra: { + created: !existing, + source: "wecom_oauth", + fieldPresence: { phone: true, email: true }, + }, + }), + }); + return { ...employee, created: !existing }; + }); + } catch (error) { + if (error instanceof WecomOAuthOnboardingError) throw error; + if (error?.code === "P2002") { + throw new WecomOAuthOnboardingError("employee_conflict", "WeCom onboarding unique field conflict"); + } + throw error; + } +} + +module.exports = { + WecomOAuthOnboardingError, + fetchWecomOAuthOnboardingProfile, + provisionWecomOAuthEmployee, +}; diff --git a/server/tests/unit/routes/wecomAuth.test.js b/server/tests/unit/routes/wecomAuth.test.js index e532089d..6fc756e2 100644 --- a/server/tests/unit/routes/wecomAuth.test.js +++ b/server/tests/unit/routes/wecomAuth.test.js @@ -43,6 +43,7 @@ async function buildApp({ config = {}, prisma = buildPrisma(), fastifyOptions = wecomEnabled: true, wecomEntryEnabled: true, wecomOauthEnabled: true, + wecomOauthOnboardingEnabled: false, wecomCorpId: "wx-test-corp", wecomAgentId: "1000001", wecomAgentSecret: "wecom-secret", @@ -142,6 +143,223 @@ describe("wecom auth routes R27", () => { await app.close(); }); + it("keeps onboarding disabled by default", async () => { + const app = await buildApp(); + + const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); + const loginResponse = await app.inject({ + method: "GET", + url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile", + }); + + expect(configResponse.statusCode).toBe(200); + expect(configResponse.json().data).toEqual(expect.objectContaining({ + onboardingEnabled: false, + onboardingUrl: null, + })); + expect(loginResponse.statusCode).toBe(404); + + await app.close(); + }); + + it("uses private-info scope only for the explicit onboarding entry", async () => { + const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); + + const configResponse = await app.inject({ method: "GET", url: "/api/auth/wecom/config" }); + const onboardingResponse = await app.inject({ + method: "GET", + url: "/api/auth/wecom/login?mode=onboard&returnTo=%2Fmobile", + }); + const loginResponse = await app.inject({ + method: "GET", + url: "/api/auth/wecom/login?returnTo=%2Fmobile", + }); + + expect(configResponse.json().data).toEqual(expect.objectContaining({ + onboardingEnabled: true, + onboardingUrl: "/api/auth/wecom/login?returnTo=%2Fmobile&mode=onboard", + })); + expect(new URL(onboardingResponse.headers.location).searchParams.get("scope")).toBe("snsapi_privateinfo"); + expect(new URL(loginResponse.headers.location).searchParams.get("scope")).toBe("snsapi_base"); + + await app.close(); + }); + + it("does not provision when private-info consent returns no user ticket", async () => { + mockWecomFetch({ userId: "wecom-onboard-no-ticket" }); + const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-onboard-no-ticket", + returnTo: "/mobile", + mode: "onboard", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe( + "https://oa.hyinda.com/login?redirect=%2Fmobile&wecom=consent_required", + ); + + await app.close(); + }); + + it("does not return a raw provider payload when code exchange fails", async () => { + const privateValue = "private-provider-value-must-not-leak"; + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, access_token: "access-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 40029, errmsg: "invalid code", privateValue }), + }); + const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true } }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-onboard-provider-failure", + returnTo: "/mobile", + mode: "onboard", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error.details).toEqual({ wecom: "userinfo_exchange_failed" }); + expect(response.body).not.toContain(privateValue); + + await app.close(); + }); + + it("does not expose credentials from provider network errors", async () => { + const privateValue = "network-error-secret-must-not-leak"; + global.fetch = vi.fn().mockRejectedValue(new Error(`request failed ${privateValue}`)); + const app = await buildApp({ + config: { + wecomOauthOnboardingEnabled: true, + wecomAgentSecret: privateValue, + }, + }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-onboard-network-failure", + returnTo: "/mobile", + mode: "onboard", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error.details).toEqual({ wecom: "token_exchange_failed" }); + expect(response.body).not.toContain(privateValue); + + await app.close(); + }); + + it("self-provisions an employee-only account after explicit private-info consent", async () => { + global.fetch = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, access_token: "access-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errcode: 0, + userid: "wecom-onboard-user", + user_ticket: "private-ticket", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errcode: 0, + userid: "wecom-onboard-user", + mobile: "19900000001", + email: "onboard@example.test", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + errcode: 0, + userid: "wecom-onboard-user", + name: "测试成员", + status: 1, + department: [2], + position: "工程师", + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ errcode: 0, department: [{ id: 2, name: "研发部" }] }), + }); + + const prisma = buildPrisma({ + wecomIdentityBinding: { + findUnique: vi.fn().mockResolvedValue(null), + upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), + }, + employee: { + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: EMPLOYEE_ID, token_version: 0 }), + }, + integrationBinding: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "department-binding-1" }), + }, + department: { + findMany: vi.fn().mockResolvedValue([{ id: "department-1" }]), + }, + employeeRole: { + create: vi.fn().mockResolvedValue({ employee_id: EMPLOYEE_ID, role_code: "employee" }), + }, + authSession: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + create: vi.fn().mockResolvedValue({ id: "session-1" }), + }, + }); + prisma.$transaction = vi.fn(async (callback) => callback(prisma)); + const app = await buildApp({ config: { wecomOauthOnboardingEnabled: true }, prisma }); + const state = signPayload({ + exp: Date.now() + 60_000, + nonce: "nonce-onboard-success", + returnTo: "/mobile", + mode: "onboard", + deviceFingerprint: "onboard-device", + }, JWT_SECRET); + + const response = await app.inject({ + method: "GET", + url: `/api/auth/wecom/callback?state=${encodeURIComponent(state)}&code=test-code`, + }); + + expect(response.statusCode).toBe(302); + expect(response.headers.location).toBe("https://oa.hyinda.com/mobile"); + expect(prisma.employeeRole.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ role_code: "employee" }), + }); + expect(prisma.wecomIdentityBinding.upsert).toHaveBeenCalled(); + expect(prisma.authSession.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ employee_id: EMPLOYEE_ID, device_fingerprint: "onboard-device" }), + }); + expect(JSON.stringify(prisma.auditLog.create.mock.calls)).not.toContain("private-ticket"); + + await app.close(); + }); + it("returns misconfigured status when corp id is missing", async () => { const app = await buildApp({ config: { wecomCorpId: "" } }); diff --git a/server/tests/unit/wecomHrMaster.service.test.js b/server/tests/unit/wecomHrMaster.service.test.js index dd83eea1..11d503f7 100644 --- a/server/tests/unit/wecomHrMaster.service.test.js +++ b/server/tests/unit/wecomHrMaster.service.test.js @@ -131,8 +131,11 @@ describe("wecom HR master sync helpers", () => { return jsonResponse({ errcode: 0, access_token: "agent-token", expires_in: 7200 }); } } - if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "contact-token") { - return jsonResponse({ errcode: 48009, errmsg: "department list not visible to contact app" }); + if (parsed.pathname === "/cgi-bin/department/list" && parsed.searchParams.get("access_token") === "agent-token") { + return jsonResponse({ + errcode: 0, + department: [{ id: 1, name: "研发部", parentid: 0, order: 1 }], + }); } if (parsed.pathname === "/cgi-bin/user/list_id" && parsed.searchParams.get("access_token") === "contact-token") { expect(init.method).toBe("POST"); @@ -146,8 +149,17 @@ describe("wecom HR master sync helpers", () => { next_cursor: "", }); } - if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "contact-token") { - return jsonResponse({ errcode: 48009, errmsg: "api forbidden for contact assistant" }); + if (parsed.pathname === "/cgi-bin/user/get" && parsed.searchParams.get("access_token") === "agent-token") { + return jsonResponse({ + errcode: 0, + userid: "oa_smoke002", + name: "测试员工", + mobile: "19912345678", + email: "smoke002@hyinda.com", + department: [1], + position: "测试工程师", + enable: 1, + }); } if (parsed.pathname === "/cgi-bin/hr/get_staff_info" && parsed.searchParams.get("access_token") === "agent-token") { expect(init.method).toBe("POST"); @@ -185,10 +197,12 @@ describe("wecom HR master sync helpers", () => { expect(result.profiles[0].externalUserId).toBe("oa_smoke002"); expect(result.profiles[0].name).toBe("测试员工"); expect(result.profiles[0].missingRequiredFields).toEqual([]); - expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "contact-token")).toBe(true); + expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "agent-token")).toBe(true); expect(calls.some((call) => call.path === "/cgi-bin/user/list_id" && call.token === "contact-token")).toBe(true); - expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "contact-token")).toBe(true); + expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "agent-token")).toBe(true); expect(calls.some((call) => call.path === "/cgi-bin/hr/get_staff_info" && call.token === "agent-token")).toBe(true); + expect(calls.some((call) => call.path === "/cgi-bin/department/list" && call.token === "contact-token")).toBe(false); + expect(calls.some((call) => call.path === "/cgi-bin/user/get" && call.token === "contact-token")).toBe(false); expect(calls.some((call) => call.secret === "deprecated-hr-secret-should-not-be-used")).toBe(false); }); }); diff --git a/server/tests/unit/wecomOAuthOnboarding.service.test.js b/server/tests/unit/wecomOAuthOnboarding.service.test.js new file mode 100644 index 00000000..3ee0ed16 --- /dev/null +++ b/server/tests/unit/wecomOAuthOnboarding.service.test.js @@ -0,0 +1,345 @@ +const { + WecomOAuthOnboardingError, + fetchWecomOAuthOnboardingProfile, + provisionWecomOAuthEmployee, +} = require("../../src/services/integrations/wecomOAuthOnboarding"); + +function jsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }; +} + +function buildRequest() { + return { + id: "request-onboard-1", + ip: "127.0.0.1", + headers: { "user-agent": "vitest" }, + }; +} + +describe("wecom OAuth onboarding service", () => { + it("combines member-authorized private fields with active contact and department data", async () => { + const calls = []; + const fetchImpl = vi.fn(async (url, init = {}) => { + const parsed = new URL(String(url)); + calls.push({ path: parsed.pathname, body: init.body ? JSON.parse(init.body) : null }); + if (parsed.pathname === "/cgi-bin/auth/getuserdetail") { + return jsonResponse({ + errcode: 0, + userid: "member-1", + mobile: "19900000001", + email: "member1@example.test", + }); + } + if (parsed.pathname === "/cgi-bin/user/get") { + return jsonResponse({ + errcode: 0, + userid: "member-1", + name: "测试成员", + status: 1, + department: [2], + position: "工程师", + }); + } + if (parsed.pathname === "/cgi-bin/department/list") { + return jsonResponse({ errcode: 0, department: [{ id: 2, name: "研发部" }] }); + } + return jsonResponse({ errcode: 40001 }, 400); + }); + + const profile = await fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-1", + userTicket: "private-ticket", + fetchImpl, + }); + + expect(profile).toEqual({ + externalUserId: "member-1", + name: "测试成员", + phone: "19900000001", + email: "member1@example.test", + departmentExternalId: "2", + departmentName: "研发部", + title: "工程师", + }); + expect(calls.find((call) => call.path === "/cgi-bin/auth/getuserdetail")?.body).toEqual({ + user_ticket: "private-ticket", + }); + }); + + it("falls back to a valid business email and rejects malformed authorized fields", async () => { + const buildFetch = (privateDetail) => vi.fn(async (url) => { + const path = new URL(String(url)).pathname; + if (path === "/cgi-bin/auth/getuserdetail") return jsonResponse(privateDetail); + if (path === "/cgi-bin/user/get") { + return jsonResponse({ + errcode: 0, + userid: "member-fields", + name: "测试成员", + status: 1, + department: [1], + }); + } + return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] }); + }); + + const profile = await fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-fields", + userTicket: "ticket", + fetchImpl: buildFetch({ + errcode: 0, + userid: "member-fields", + mobile: "19900000006", + email: " ", + biz_mail: "Business.User@Example.test", + }), + }); + expect(profile.email).toBe("business.user@example.test"); + + await expect(fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-fields", + userTicket: "ticket", + fetchImpl: buildFetch({ + errcode: 0, + userid: "member-fields", + mobile: "1", + email: "not-an-email", + }), + })).rejects.toMatchObject({ code: "missing_required_field" }); + }); + + it("does not expose request credentials when the provider request fails", async () => { + const privateTicket = "private-ticket-must-not-leak"; + let caught; + try { + await fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token-must-not-leak", + externalUserId: "member-network", + userTicket: privateTicket, + fetchImpl: vi.fn().mockRejectedValue(new Error(`network failed ${privateTicket}`)), + }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ code: "wecom_api_failed" }); + expect(JSON.stringify(caught)).not.toContain(privateTicket); + expect(JSON.stringify(caught)).not.toContain("agent-token-must-not-leak"); + }); + + it("rejects inactive members and identity mismatches", async () => { + const mismatchedFetch = vi.fn(async (url) => { + const path = new URL(String(url)).pathname; + if (path === "/cgi-bin/auth/getuserdetail") { + return jsonResponse({ errcode: 0, userid: "another-member", mobile: "19900000002", email: "a@example.test" }); + } + if (path === "/cgi-bin/user/get") { + return jsonResponse({ errcode: 0, userid: "member-2", name: "成员", status: 1, department: [1] }); + } + return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] }); + }); + + await expect(fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-2", + userTicket: "ticket", + fetchImpl: mismatchedFetch, + })).rejects.toMatchObject({ code: "identity_mismatch" }); + + const inactiveFetch = vi.fn(async (url) => { + const path = new URL(String(url)).pathname; + if (path === "/cgi-bin/auth/getuserdetail") { + return jsonResponse({ errcode: 0, userid: "member-2", mobile: "19900000002", email: "a@example.test" }); + } + if (path === "/cgi-bin/user/get") { + return jsonResponse({ errcode: 0, userid: "member-2", name: "成员", status: 2, department: [1] }); + } + return jsonResponse({ errcode: 0, department: [{ id: 1, name: "总部" }] }); + }); + + await expect(fetchWecomOAuthOnboardingProfile({ + accessToken: "agent-token", + externalUserId: "member-2", + userTicket: "ticket", + fetchImpl: inactiveFetch, + })).rejects.toMatchObject({ code: "member_inactive" }); + }); + + it("self-provisions only the employee role and stores a dehydrated audit record", 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 }), + update: vi.fn(), + }, + integrationBinding: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: "department-binding-1" }), + }, + department: { + findMany: vi.fn().mockResolvedValue([{ id: "department-1" }]), + findUnique: vi.fn(), + create: vi.fn(), + }, + employeeRole: { + create: vi.fn().mockResolvedValue({ employee_id: "employee-1", role_code: "employee" }), + }, + auditLog: { + create: vi.fn().mockResolvedValue({ id: "audit-1" }), + }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) }; + + const result = await provisionWecomOAuthEmployee({ + prisma, + request: buildRequest(), + profile: { + externalUserId: "member-1", + name: "测试成员", + phone: "19900000001", + email: "member1@example.test", + departmentExternalId: "2", + departmentName: "研发部", + title: "工程师", + }, + }); + + expect(result).toEqual({ id: "employee-1", token_version: 0, created: true }); + expect(tx.employeeRole.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ role_code: "employee" }), + }); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actor_id: "employee-1", + action: "AUTH_WECOM_SELF_PROVISIONED", + extra: { + created: true, + source: "wecom_oauth", + fieldPresence: { phone: true, email: true }, + }, + }), + }); + const persisted = JSON.stringify({ + role: tx.employeeRole.create.mock.calls, + audit: tx.auditLog.create.mock.calls, + }); + expect(persisted).not.toContain("private-ticket"); + expect(persisted).not.toContain("19900000001"); + expect(persisted).not.toContain("member1@example.test"); + }); + + it("rejects contact data that resolves to multiple OA employees", async () => { + const tx = { + wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) }, + employee: { + findMany: vi.fn().mockResolvedValue([ + { id: "employee-1", status: "active" }, + { id: "employee-2", status: "active" }, + ]), + }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) }; + + await expect(provisionWecomOAuthEmployee({ + prisma, + request: buildRequest(), + profile: { + externalUserId: "member-conflict", + name: "冲突成员", + phone: "19900000003", + email: "conflict@example.test", + departmentExternalId: "1", + departmentName: "总部", + title: "员工", + }, + })).rejects.toBeInstanceOf(WecomOAuthOnboardingError); + }); + + it("does not rebind an OA employee that already belongs to another WeCom identity", async () => { + const tx = { + wecomIdentityBinding: { findUnique: vi.fn().mockResolvedValue(null) }, + employee: { + findMany: vi.fn().mockResolvedValue([{ + id: "employee-1", + status: "active", + wecom_identity_binding: { external_user_id: "another-member" }, + }]), + }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) }; + + await expect(provisionWecomOAuthEmployee({ + prisma, + request: buildRequest(), + profile: { + externalUserId: "member-new", + name: "测试成员", + phone: "19900000004", + email: "member4@example.test", + departmentExternalId: "1", + departmentName: "总部", + title: "员工", + }, + })).rejects.toMatchObject({ code: "employee_conflict" }); + }); + + it("completes an existing bound employee profile without changing roles", async () => { + const existing = { + id: "employee-1", + status: "active", + wecom_identity_binding: { external_user_id: "member-1" }, + }; + const tx = { + wecomIdentityBinding: { + findUnique: vi.fn().mockResolvedValue({ employee: existing }), + upsert: vi.fn().mockResolvedValue({ id: "binding-1" }), + }, + employee: { + findMany: vi.fn().mockResolvedValue([existing]), + update: vi.fn().mockResolvedValue({ id: "employee-1", token_version: 3 }), + create: vi.fn(), + }, + integrationBinding: { + findFirst: vi.fn().mockResolvedValue({ local_id: "department-1" }), + }, + department: { + findUnique: vi.fn().mockResolvedValue({ id: "department-1", archived_at: null }), + }, + employeeRole: { create: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({ id: "audit-1" }) }, + }; + const prisma = { $transaction: vi.fn(async (callback) => callback(tx)) }; + + const result = await provisionWecomOAuthEmployee({ + prisma, + request: buildRequest(), + profile: { + externalUserId: "member-1", + name: "测试成员", + phone: "19900000001", + email: "member1@example.test", + departmentExternalId: "2", + departmentName: "研发部", + title: "工程师", + }, + }); + + expect(result).toEqual({ id: "employee-1", token_version: 3, created: false }); + expect(tx.employee.update).toHaveBeenCalled(); + expect(tx.employee.create).not.toHaveBeenCalled(); + expect(tx.employeeRole.create).not.toHaveBeenCalled(); + expect(tx.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: "AUTH_WECOM_PROFILE_COMPLETED" }), + }); + }); +});