完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)

这个提交包含在:
2026-07-26 23:44:44 +08:00
父节点 9a624700d0
当前提交 130518f471
共修改 21 个文件,包含 1350 行新增和 55 行删除
@@ -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
+90
查看文件
@@ -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=<internal-path>`.
- Ordinary entry: the same route with `mode=login|bind`.
- Provider chain: `snsapi_privateinfo` -> `/cgi-bin/auth/getuserinfo` ->
`/cgi-bin/auth/getuserdetail` with `{ "user_ticket": "<short-lived>" }`.
- 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
```
@@ -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 目标。
@@ -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/附件目标、意外容器名或非活动端口:停止部署,不创建或迁移数据。
@@ -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`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。
@@ -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`,因此没有员工、角色、会话、付款或其他业务写入。
+3
查看文件
@@ -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
+4 -4
查看文件
@@ -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://<oa-domain>/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:<APP_HOST_PORT>`
## 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=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `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 <previous-tag>`
- [ ] 重启并探活:
- [ ] `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/发布记录中写明原因、影响范围和恢复时间
+8 -2
查看文件
@@ -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 暴露的回环端口"
+1 -1
查看文件
@@ -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}
+14 -9
查看文件
@@ -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:<APP_HOST_PORT> -> 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 </dev/null
curl -fsS http://127.0.0.1:3000/ready
SERVER_HOST_PORT="$(docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env port server 3000)"
curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT##*:}/ready"
```
重建后必须补做:
+7 -1
查看文件
@@ -8,8 +8,14 @@ set -euo pipefail
DEPLOY_USER="${DEPLOY_USER:-deploy}"
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
OA_HOST="${OA_HOST:-oa.hyinda.com}"
APP_HOST_PORT="${APP_HOST_PORT:-3000}"
CADDY_CONFIG_PATH="${CADDY_CONFIG_PATH:-/etc/caddy/Caddyfile}"
if [[ ! "$APP_HOST_PORT" =~ ^[0-9]+$ ]] || (( APP_HOST_PORT < 1 || APP_HOST_PORT > 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"
+2 -2
查看文件
@@ -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) {
+7 -6
查看文件
@@ -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。
+1
查看文件
@@ -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 ?? "",
+92 -11
查看文件
@@ -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);
@@ -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,
@@ -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,
};
+218
查看文件
@@ -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: "[email protected]",
}),
})
.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: "" } });
@@ -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: "[email protected]",
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);
});
});
@@ -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: "[email protected]",
});
}
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: "[email protected]",
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: "[email protected]",
}),
});
expect(profile.email).toBe("[email protected]");
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: "[email protected]" });
}
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: "[email protected]" });
}
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: "[email protected]",
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("[email protected]");
});
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: "[email protected]",
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: "[email protected]",
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: "[email protected]",
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" }),
});
});
});