完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline)
这个提交包含在:
@@ -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
|
||||
|
||||
@@ -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`,因此没有员工、角色、会话、付款或其他业务写入。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/发布记录中写明原因、影响范围和恢复时间
|
||||
|
||||
|
||||
|
||||
@@ -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 暴露的回环端口"
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
重建后必须补做:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户