比较提交

...
3 次代码提交
作者 SHA1 备注 提交日期
wangsli 64ed844bfe 补强生产数据路径与备份来源护栏(07-26-internal-ops-recovery-baseline)
ci-minimal / check-minimal (push) Waiting to run
ci-e2e / e2e (push) Waiting to run
2026-07-27 06:37:47 +08:00
wangsli 23342035ee 加固部署隔离与备份来源契约(07-26-internal-ops-recovery-baseline) 2026-07-27 06:13:02 +08:00
wangsli 130518f471 完善企微开通与部署端口契约(07-26-internal-ops-recovery-baseline) 2026-07-26 23:44:44 +08:00
共修改 29 个文件,包含 1886 行新增和 145 行删除
+210 -9
查看文件
@@ -7,8 +7,7 @@ operations require explicit approval and evidence.
## Rules
- Use `SYNC_ENV=0 bash deploy/trialrun/deploy.sh` only when deployment is
approved; do not overwrite remote `.env`.
- Use `SYNC_ENV=0 POSTGRES_DATA_DIR=<absolute> STORAGE_HOST_DIR=<absolute> DEPLOY_DATA_MODE=<existing|initialize> bash deploy/trialrun/deploy.sh` only when deployment is approved; do not overwrite remote `.env`.
- When `server/src/config.js` reads a new environment key, add the same key to
`deploy/trialrun/.env.example` before deployment; `deploy/trialrun/deploy.sh`
runs `node deploy/trialrun/validate-env-contract.js` at step 0 and must fail
@@ -31,7 +30,8 @@ operations require explicit approval and evidence.
### 2. Signatures
- Command: `node deploy/trialrun/validate-env-contract.js`
- Deployment entry: `SYNC_ENV=0 bash deploy/trialrun/deploy.sh`
- Deployment entry requires `SYNC_ENV=0`, absolute `POSTGRES_DATA_DIR`, absolute
`STORAGE_HOST_DIR` and `DEPLOY_DATA_MODE=existing|initialize`.
- Contract sources:
- `server/src/config.js`
- `deploy/trialrun/.env.example`
@@ -47,6 +47,10 @@ operations require explicit approval and evidence.
allowlist in `validate-env-contract.js`.
- `SYNC_ENV=0` is the default public deploy mode; it does not overwrite remote
`.env`.
- Relative data-path defaults are allowed only for direct local Compose.
`deploy.sh` requires explicit absolute host paths and a data mode; `existing`
verifies the PGDATA directory contains `PG_VERSION`, while `initialize` is
the only mode allowed to create missing directories.
### 4. Validation & Error Matrix
@@ -54,6 +58,10 @@ operations require explicit approval and evidence.
- Service omits `env_file: ./.env` -> contract exits non-zero.
- Unauthorized compose override -> contract exits non-zero.
- Remote `.env` missing -> deploy step 4 exits before migration.
- Missing/relative/unsafe/overlapping data paths or missing data mode -> deploy
exits locally before provenance, synchronization or SSH.
- `existing` target missing `PG_VERSION` or storage directory -> remote deploy
exits before Compose starts DB.
### 5. Good/Base/Bad Cases
@@ -83,7 +91,9 @@ SYNC_ENV=0 bash deploy/trialrun/deploy.sh
```bash
# Add the key to deploy/trialrun/.env.example first.
node deploy/trialrun/validate-env-contract.js
SYNC_ENV=0 bash deploy/trialrun/deploy.sh
SYNC_ENV=0 POSTGRES_DATA_DIR=/srv/hyb-oa/pgdata \
STORAGE_HOST_DIR=/srv/hyb-oa/storage DEPLOY_DATA_MODE=existing \
bash deploy/trialrun/deploy.sh
```
## Scenario: Public Smoke Navigation Labels
@@ -279,7 +289,8 @@ const REQUIRED_ADMIN_NAV_KEYS = ["systemSettings"];
### 2. Signatures
- Provenance gate: `node deploy/trialrun/check-source-provenance.mjs`.
- Standard release: `SYNC_ENV=0 bash deploy/trialrun/deploy.sh`.
- Standard release additionally supplies the explicit absolute data-path and
data-mode contract required by `deploy.sh`.
- Build/runtime inputs: `BUILD_SHA=<full-commit>` and
`VITE_BUILD_SHA=<12-char-commit>`.
- Runtime outputs: `/health.data.buildSha`, `/ready.data.buildSha` and the
@@ -517,6 +528,175 @@ 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: Compose Project And Data-Path Isolation
### 1. Scope / Trigger
- Trigger: changing Compose identity, host data mounts, database publication,
deployment entrypoints or operational timers.
- Purpose: ensure one approved Compose project owns one verified DB/storage pair
without fixed global container names or accidental shared PGDATA.
### 2. Signatures
- Every deploy/timer Compose command uses `-p "$COMPOSE_PROJECT_NAME"`.
- PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`.
- Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`.
- Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute
`STORAGE_HOST_DIR`, `DEPLOY_DATA_MODE=existing|initialize`.
- DB has no host `ports`; server remains loopback-only.
### 3. Contracts
- Compose files must not set `container_name`. Operational commands resolve
services through the approved project, never a historical global name.
- A project-name change and its DB/storage path selection are one atomic,
separately approved downtime operation. Never start two PostgreSQL instances
against one PGDATA.
- Freeze the old project name from `docker compose ls`, not from a directory
guess. Record immutable image IDs/digests, successful migration count, fixed
business-table counts, resolved mounts and Caddy target before any write.
- Relative defaults preserve local trialrun compatibility, but production must
use explicit absolute command inputs and verify them against active container
mounts before any start, backup or migration.
- `existing` is the normal migration mode and refuses a missing PGDATA marker
or storage directory. `initialize` is only for an approved empty first start;
only that mode may create data directories.
- Remote readiness may use host curl, but build-SHA JSON validation runs with
Node inside the server container; the Docker host does not need Node.
- For an existing cutover, stop the old PostgreSQL completely, copy PGDATA and
storage into pre-verified empty absolute targets while preserving ownership,
permissions, links and extended attributes, and retain the old directories
untouched and unmounted for the observation period. Do not rename away the
only pre-migration rollback data and do not copy a running PGDATA.
### 4. Validation & Error Matrix
- DB `ports` appears -> environment contract fails.
- Any fixed `container_name` appears -> environment contract fails.
- Required data mount loses its parameter -> environment contract fails.
- Deploy/timer omits `-p` -> environment contract fails.
- Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an
`existing` PGDATA lacks `PG_VERSION` -> fail before DB start.
- New project resolves the same PGDATA while the old DB runs -> stop before
Compose start; do not use service health as permission to continue.
- Old project name was inferred, immutable image ID or baseline counts are
missing, or target copy is non-empty -> stop the cutover.
### 5. Tests Required
- `node deploy/trialrun/validate-env-contract.js`.
- The isolated backup/restore drill mutates Compose with a DB port and fixed
name and proves the contract rejects it.
- `bash -n` covers deploy, timer, backup and restore scripts.
### 6. Good/Base/Bad Cases
- Good: one approved project resolves one stopped-and-migrated PGDATA and the
matching active storage path; deploy, timer and backup all use that identity.
- Base: direct local Compose uses the documented relative defaults under one
project; those defaults are never accepted by formal `deploy.sh`.
- Bad: start a renamed project before the old DB stops, or let it create a new
empty directory and accept a green health check as proof of data continuity.
- Bad: move the only PGDATA into the new path, migrate it in place, then assume
the old container name alone is a rollback point.
### 7. Wrong vs Correct
#### Wrong
```bash
docker-compose up -d db
DB_CONTAINER=hyb-oa-trialrun-db ./scripts/backup-all.sh
```
#### Correct
```bash
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml \
--env-file deploy/trialrun/.env up -d db
mapfile -t DB_CONTAINERS < <(docker-compose -p "$COMPOSE_PROJECT_NAME" \
-f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db)
test "${#DB_CONTAINERS[@]}" -eq 1
DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \
STORAGE_DIR_HOST=<verified-active-storage> ./scripts/backup-all.sh
```
## Scenario: Paired Backup And Shadow-Only Restore
### 1. Scope / Trigger
@@ -528,7 +708,7 @@ bash scripts/qa/run-local-candidate.sh full
### 2. Signatures
- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER=<name> ./scripts/backup-all.sh`.
- Paired backup: `DB_TOOL_MODE=docker DB_CONTAINER=<compose-resolved-id> STORAGE_DIR_HOST=<verified-active-directory> BUILD_SHA=<actual-build-sha> SOURCE_IMAGE_DIGEST=<actual-sha256-image-id> ./scripts/backup-all.sh`; at least one source identity is required.
- Optional safe batch identifier: `BACKUP_RUN_ID=<safe-id>`.
- Manifest writer: host `node` is preferred; host `python3` is an equivalent
fallback for Docker-only runtime machines.
@@ -544,7 +724,11 @@ bash scripts/qa/run-local-candidate.sh full
- A complete recovery point is one batch directory containing `database.dump`,
`storage.tar.gz` and a final `manifest.json`. The manifest contains only safe
relative names, byte sizes, SHA-256 hashes, batch time and optional build SHA.
relative names, byte sizes, SHA-256 hashes, batch time, actual build SHA when
known and actual source image digest when supplied.
- `BUILD_SHA` supplied by the caller takes precedence over local Git fallback.
On a non-Git runtime host, at least `SOURCE_IMAGE_DIGEST=sha256:<64-hex>` is
mandatory. Never label a legacy backup with the commit planned for deployment.
- Manifest generation uses exclusive creation and preserves the same schema
under Node and Python 3; if neither runtime exists, fail before publishing the
final batch directory.
@@ -552,6 +736,10 @@ bash scripts/qa/run-local-candidate.sh full
the running database. Confirm that the selected PostgreSQL container's database
and user match at least one active application `DATABASE_URL`, then pass the
verified runtime values without logging their contents.
- Docker backup/restore has no default container name. Resolve the unique DB
container with `compose -p <approved-project> ... ps -q db` and fail when the
result is empty or ambiguous. Storage backup likewise requires an explicit
`STORAGE_DIR_HOST`; it must never infer `STORAGE_DIR` or a repository default.
- The DB dump must pass `pg_restore --list` and the storage archive must pass
`tar -tzf` before the manifest may say `complete`.
- DB restore queries `current_database()` and compares it exactly with
@@ -565,6 +753,10 @@ bash scripts/qa/run-local-candidate.sh full
people, rollback point and post-restore `/ready`/permission checks.
- Copy the whole completed batch to approved off-host/off-disk storage; a batch
left only on the application host is not accepted recovery evidence.
- A real-format shadow restore compares successful migration count and a fixed
set of business-table counts with the pre-backup baseline, compares extracted
regular-file count with the active storage source, and runs `/ready` using a
schema-compatible image. `/ready` alone is not recovery evidence.
### 4. Validation & Error Matrix
@@ -582,9 +774,14 @@ bash scripts/qa/run-local-candidate.sh full
only when the explicit target container and required DB variables exist.
- Neither Node nor Python 3 is available -> fail manifest generation and remove
the temporary batch; do not publish a partial recovery point.
- Invalid build SHA/image digest, or neither identity available on a non-Git
host -> fail before DB or storage backup starts.
- Deployment `.env`, PostgreSQL container and active application connection do
not identify the same database/user -> stop the live backup; never guess
credentials or expose values in diagnostics.
- Shadow migration count, any fixed table count, attachment regular-file count
or compatible-image `/ready` differs from baseline -> reject the recovery
point and do not enter production cutover.
### 5. Good/Base/Bad Cases
@@ -592,6 +789,8 @@ bash scripts/qa/run-local-candidate.sh full
host, and a disposable shadow drill restores the DB marker and attachment hash.
- Good: a Docker-only host uses Python 3 to write the same manifest contract
after the active application connection is matched to the database container.
- Good: a legacy container without `/ready.data.buildSha` records its inspected
`sha256:` image ID and leaves `buildSha` null rather than inventing provenance.
- Base: backup succeeds locally but off-host copy and deployed `/ready` evidence
remain explicitly pending; do not claim formal recovery readiness.
- Bad: source a stale deployment `.env` and treat a failed dump as a prompt to
@@ -606,7 +805,7 @@ bash scripts/qa/run-local-candidate.sh full
- Negative assertions for wrong DB confirmation, corrupt DB/tar, non-empty
target and dangerous tar path.
- Validate manifest relative paths, absence of connection secrets and matching
SHA-256 values.
SHA-256 values, explicit build-SHA precedence and image-digest preservation.
- Run the synthetic drill with Node available and with a `PATH` that excludes
Node but includes Python 3; both paths must complete the same round trip.
- Real-instance restore and `/ready` remain separately approved evidence and
@@ -629,6 +828,8 @@ source deploy/trialrun/.env
./scripts/qa/run-backup-restore-drill.sh
# Before an approved live backup, compare the selected DB container identity
# with an active app connection using redacted boolean diagnostics.
DB_TOOL_MODE=docker DB_CONTAINER=<verified-container> ./scripts/backup-all.sh
DB_TOOL_MODE=docker DB_CONTAINER=<verified-container> \
STORAGE_DIR_HOST=<verified-active-storage> \
SOURCE_IMAGE_DIGEST=<verified-sha256-image-id> ./scripts/backup-all.sh
# Real restore remains an approved incident operation after shadow evidence.
```
+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
```
@@ -21,7 +21,7 @@ isolated drill
- 一个批次只使用一个 `run_id`,由上层入口传递明确的 `OUT_FILE` 给现有底层脚本。
- 先把产物写入批次临时目录;DB 和附件均成功并通过完整性检查后,再写 manifest 并把批次标记为完整。
- manifest 使用稳定、脱敏的键:`schemaVersion`、`runId`、`createdAt`、`buildSha`(如可安全取得)、两份相对文件名、字节数和 SHA-256。
- manifest 使用稳定、脱敏的键:`schemaVersion`、`runId`、`createdAt`、`buildSha`(真实可知时)、`sourceImageDigest`(实测可知时)、两份相对文件名、字节数和 SHA-256;两类来源标识至少有一项。
- 不把连接串、容器密码、存储源路径、真实业务文件清单或主机绝对路径写入 manifest。
- 本任务不负责异机传输实现;runbook 要求操作者把完整批次复制到异机/异盘,并记录保留周期。
@@ -59,3 +59,76 @@ 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 或数据目录覆盖。
- Compose 不再固定 `container_name`;deploy 与 timer 通过 `-p` 显式选择同一项目,PostgreSQL 与附件宿主路径分别由 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 参数化。
- 项目名隔离不是独立部署步骤。项目名、容器身份和数据目录必须在同一次停机切换中启用;旧实例未停止时不得让新 PostgreSQL 挂载同一 PGDATA。
- DB 不发布宿主端口;应用仅通过 Compose 内部网络访问 `db`。环境契约必须拒绝 DB `ports`、固定容器名或未参数化数据目录回退。
- Docker 备份/恢复必须显式传入由 `compose ... ps -q db` 得到的已核验容器 ID;附件备份必须显式传入活动挂载源,两个脚本都不猜默认值。
- 远端 `/ready` 构建标识在 server 容器内用 Node 校验,宿主机只负责 curl 和 Compose,不要求安装 Node。
- `deploy.sh` 不解析或 source 远端 `.env` 来猜宿主路径;调用者必须在发布命令中显式传入受限字符集的绝对 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 和数据模式。`existing` 要求目录已存在且 PGDATA 含 `PG_VERSION`;`initialize` 才允许创建空目标。
- 本地直接 Compose 可继续使用示例相对默认值,但正式发布入口拒绝相对路径,从而避免项目名变化时 Docker 以 root 静默创建第二套空目录。
- manifest 的 `buildSha` 优先使用调用者显式值,否则仅在本地 Git 工作树存在时推导;另以可选 `sourceImageDigest` 记录活动镜像不可变标识。旧实例 build SHA 不可知时只记录实测 digest,不填写目标提交。
### 生产统一顺序
1. 另行获批后,从活动 DB 和活动附件挂载源重新生成成对异机备份作为灾难回滚点;旧批次因混入非活动附件目录只保留审计,并记录活动数据挂载、环境摘要、容器镜像和 Caddy 端口。
2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。
3. 停机后把活动 PGDATA 和附件复制到新的显式绝对目录,复制前目标必须为空;保留旧目录原位且不再挂载写入,直到观察期结束。复制仅在旧 PostgreSQL 完全停止后执行,并保留所有权、权限、链接和扩展属性。
4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。
5. 公网构建标识、数据计数、角色、企微入口和关键只读检查通过后更新运维 timer;失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。
@@ -10,14 +10,32 @@
- [x] 更新 runbook/checklist 和部署规约,区分普通备份、影子演练和真实事故恢复。
- [x] 运行任务测试计划和 Trellis 治理门禁;按实际结果记录本地 RTO,不伪造 `/ready` 或异机证据。
- [x] 在单独授权后完成真实运行来源、职责分离、异机副本和 `/ready` 的只读核查,并如实记录 `NO_GO` 缺口。
- [x] 经明确授权完成提交发布和真实 DB+附件+manifest 成对异机备份,两端完整性复验通过。
- [x] 经明确授权生成并复制过 DB+附件+manifest 批次;后续发现附件来自非活动目录,已将该批次降级为无效恢复证据并保留审计。
- [ ] 经后续明确授权,从活动 DB 与活动 server 附件挂载源重新生成正确批次并完成异机复验。
- [ ] 经后续明确授权完成真实职责分配、部署/迁移和真实格式影子恢复后复验。
- [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/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。
- [x] 完成本地 Compose/备份加固:移除固定容器名和 DB 宿主端口,参数化数据目录,deploy/timer 显式 project,备份来源缺失硬失败,远端构建标识改由容器内 Node 核验。
- [x] 增加隐式附件源、隐式 DB 容器、DB 端口与固定容器名回退的负向契约演练;未部署或启用新 Compose。
- [x] 收紧正式 deploy 数据路径:显式绝对路径、`existing|initialize` 模式、既有 PGDATA 标记和禁止 Docker 静默建空目录。
- [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。
- [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。
- [ ] 部署后验证唯一 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 +46,27 @@
- `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`
- `bash -n deploy/trialrun/install-warch3-ops-timer.sh`
## 本轮本地验证证据(2026-07-26)
- 后端全量 Vitest:162 个测试文件、1000 个测试通过;安全补修后企微聚焦回归为 3 个文件、30 个测试通过。
- `prisma validate`、四个企微改动文件 `node --check`、部署环境契约和部署脚本语法均通过。
- 本地实现没有开启 `WECOM_OAUTH_ONBOARDING_ENABLED`,没有部署、迁移、写真实员工/角色或修改企微后台权限。
## 本地部署加固证据(2026-07-27)
- Shell 语法、部署环境契约、Compose 示例配置解析、Trellis task/governance、敏感扫描、文档注册、流程合同、Prisma 和 `git diff --check` 通过。
- 合成备份恢复在 Node 路径和移除 Node 的 Python 3 回退路径均通过,最近两次本地参考 RTO 均为 6 秒;负向覆盖隐式附件源、隐式 DB 容器、DB 宿主端口、固定容器名和 deploy 缺失 project name。
- 本阶段授权仅覆盖本地文件、测试、规约和提交,不包含生产备份、停机、迁移、部署、timer 安装或企微写入。
- 已确认旧异机批次混用了活动 DB 与非活动附件目录,保留审计但不再作为活动实例成对恢复证据;正确批次须另行批准重做。
- 复核补修后,正式 deploy 的相对/重叠路径负向门禁、显式来源标识、Node 路径以及无 Node/无 Git 的 Python 3 + digest-only 路径均通过;最近两次合成 RTO 均为 7 秒。
## 回滚点
@@ -35,3 +74,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,18 @@
- 恢复演练至少证明: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 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。
- 正式 `deploy.sh` 必须由调用者显式提供绝对 `POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE=existing|initialize`;既有实例默认只接受包含 `PG_VERSION` 的现存 PGDATA,不得让 Docker 静默创建空目录。
- 成对备份必须尊重调用者显式提供的真实来源标识。当前旧实例没有 build SHA 时记录活动镜像不可变 digest,不得用待部署提交冒充备份来源。
- 部署健康检查必须从 Compose 解析实际宿主端口,并校验后端 `/ready`、前端 marker 与批准提交一致;应用已提交的 Prisma migrations 但禁止 reset 或改写历史。
## Acceptance Criteria
@@ -41,13 +57,23 @@
- [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 返回同一短构建标识。
- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。
- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。
- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `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 +85,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 deploy/trialrun/install-warch3-ops-timer.sh`
- `node deploy/trialrun/validate-env-contract.js`
- 获批部署窗口内执行迁移状态、Compose 数据挂载、`/ready`、前端 marker、Caddy 公网路由和回滚容器只读复验。
## Key Decision
先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。
企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料,不修改全局员工字段可空性;自助开通只授予 `employee`,业务角色继续由管理员审计写入。
## Local Evidence
- 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。
@@ -75,11 +110,11 @@
- 获批只读核查结果见 `research/live-read-only-baseline.md`,结论为 `NO_GO_FOR_FORMAL_INTERNAL_USE`。
- 公开 HTTPS、TLS、HSTS 和 `/ready` 当前可用,但运行环境没有后端/前端构建标识,且远端迁移少于本地当前基线。
- 真实角色只有一个活跃账号,财务和付款复核候选均为 0,不满足 maker-checker。
- 只读核查时的历史 DB/附件备份约 55 天未更新、无 manifest,且与运行数据位于同一设备;获批后已生成新的 DB+附件+manifest 完整批次并复制到独立主机,两端校验一致。
- 只读核查时的历史 DB/附件备份约 55 天未更新、无 manifest,且与运行数据位于同一设备;后续异机批次虽通过传输校验,但复核发现混用了非活动附件目录,已降级为无效恢复证据,须另行批准后从活动 DB/附件重新生成。
- 当前代码已在获批后以普通快进方式发布到 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`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。
@@ -63,21 +63,39 @@
服务当前可访问不等于运行与恢复底线通过。进入下一步前必须按顺序完成:
1. 已将当前获批代码以普通快进方式发布到 upstream,来源门禁复验为 `PASS`。
2. 已在获批后用新成对备份合同生成 DB + 附件 + manifest,并复制到独立主机;两端 manifest 和产物校验通过。
2. 重新从活动 DB 与活动 server 附件挂载源生成 DB + 附件 + manifest,并复制到独立主机;此前批次混用了非活动附件目录,不能作为活动实例的成对恢复证据。
3. 明确至少一名财务处理人和另一名 `manager`/`ceo` 付款复核人;系统管理职责单独分配,应急 `admin` 独立托管。
4. 通过获批部署任务升级代码和 3 项迁移,验证 `BUILD_SHA`、前端 marker、`/ready`、权限矩阵和回滚点。
5. 在独立影子目标恢复同一批次 DB/附件,验证应用 `/ready` 和关键附件引用,记录真实 RPO/RTO。
发布和远程备份已分别获得授权并完成;真实角色写入、部署和影子恢复仍须继续取得明确授权,不能由既有批准推导。
## 后续获批写入:成对异机备份
## 后续获批写入:已降级的异机备份批次
- 执行日期:2026-07-26。
- 授权范围:生成真实 DB、附件和 manifest 成对备份,并复制到用户指定的另一台主机。
- 源机与目标机设备指纹不同;未记录主机名、地址、SSH 名称、账号或完整目录。
- 完整批次:`hyb-oa-backup-20260726T133923Z-live`,包含 `database.dump`、`storage.tar.gz`、`manifest.json` 三个预期文件,总大小约 9.6 MiB。
- 源端完整性检查通过;目标端重新计算 DB/附件 SHA-256 并与 manifest 核对通过;两端 manifest 哈希一致。
- 批次 `hyb-oa-backup-20260726T133923Z-live` 的三份产物及传输校验本身完整,但后续拓扑复核证明其来源是“活动 DB + 非活动附件目录”。该批次保留作审计,不再计为活动实例的成对恢复点。
- 该批次的附件归档含 54 个文件,而活动 server 实际附件挂载为空,活动库附件类记录也均为 0;两端哈希一致只能证明传输一致,不能证明源选择正确。
- 目标批次目录权限为 `700`;传输失败产生的单文件残缺副本已精确删除,本机临时暂存数据已安全清理。
- 运行主机没有宿主 Node.js,因此为成对备份入口增加 Python 3 manifest 等价回退;无 Node 路径已通过完整合成恢复演练,本地参考 RTO 仍为 6 秒。
- 运行中的 PostgreSQL 容器参数与部署目录环境文件存在漂移;本次只在运行容器参数与活动服务数据库连接相互匹配后执行备份,未修改环境文件。
- 本次未执行恢复、部署、迁移、角色修改、企微写入或业务写入;真实格式影子恢复和恢复后 `/ready` 仍无证据。
- 必须在另行批准后,显式提供经挂载核验的 `STORAGE_DIR_HOST` 和 Compose 解析出的 DB 容器 ID,重新生成并复制正确批次。本次未执行恢复、部署、迁移、角色修改、企微写入或业务写入;真实格式影子恢复和恢复后 `/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`,因此没有员工、角色、会话、付款或其他业务写入。
## 2026-07-27 复核后的执行授权边界
- 外部复核确认提交 `2334203` 的 Compose、端口、容器名、备份来源硬失败和负向契约方向正确,同时发现正式 deploy 仍会创建默认目录、相对默认值缺少生产护栏、非 Git 主机 manifest 无来源标识三项本地缺口。
- 已批准在补齐上述缺口后执行:发布批准提交、生产只读冻结、从活动 DB/附件重做异机成对备份、在隔离影子目标恢复并验收。该授权不包含停机、生产数据目录复制、迁移、新 Compose 启动、Caddy/timer 修改、企微写入或角色写入。
- 冻结证据必须包含实测旧 Compose project、不可变镜像 ID/digest、成功 migration 数和固定关键表计数;影子恢复必须逐表比对计数、比对附件普通文件数并运行 schema 兼容镜像 `/ready`。
- 当前旧实例没有 build SHA,重做备份不得写入待部署提交。manifest 记录活动容器的实测 `sha256:` 镜像 ID;新版本部署后的备份才能记录其真实 build SHA。
- 后续停机预案采用“旧 PostgreSQL 完全停止后复制到空新目录,旧目录原位保留且不再写入”,不把原子改名后的唯一数据副本当作回滚点。
+7
查看文件
@@ -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
@@ -95,6 +98,9 @@ WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE=
IM_NOTIFIER_ENABLED=true
# ===== 数据库(Compose 内部连接) =====
# Compose 项目名由 deploy/timer 命令通过 -p 显式传入,不写入本文件。
# 以下相对目录仅供本地直接 Compose;正式 deploy.sh 必须另行显式传入绝对路径和 DEPLOY_DATA_MODE。
POSTGRES_DATA_DIR=./data/postgres
POSTGRES_DB=<managed-db-name>
POSTGRES_USER=<managed-db-user>
POSTGRES_PASSWORD=<managed-db-password>
@@ -102,6 +108,7 @@ DATABASE_URL=<managed-postgresql-url>
# SHADOW_DATABASE_URL=<managed-shadow-postgresql-url>
# ===== 存储与上传 =====
STORAGE_HOST_DIR=./data/storage
STORAGE_DIR=/data/storage
STORAGE_DRIVER=local
S3_ENDPOINT=http://minio:9000
+25 -11
查看文件
@@ -14,17 +14,24 @@
- [ ] 已确认 `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`
- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对 `POSTGRES_DATA_DIR`、绝对 `STORAGE_HOST_DIR` 和 `DEPLOY_DATA_MODE`,并从本地执行发布入口
- [ ] 已通过 `https://<oa-domain>/ready` 或首页登录完成最小探活
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份
- [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据
- [ ] 不导入历史审批单、通知历史、审计日志、聊天记录和涉密正文附件
- [ ] 已确认远端部署目录不是 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)
- [ ] 已冻结唯一 `COMPOSE_PROJECT_NAME`,所有 deploy、timer、runbook 命令均显式使用 `-p`,未依赖目录名或固定容器名
- [ ] 已用 `docker compose ls` 实测旧 Compose project;停止旧栈命令使用旧 project,新栈使用批准的新 project
- [ ] 已核对 `POSTGRES_DATA_DIR` 与 `STORAGE_HOST_DIR` 指向活动数据;项目名与数据目录只在同一次获批停机窗口切换
- [ ] 正式 deploy 使用绝对数据路径;既有实例使用 `DEPLOY_DATA_MODE=existing`,PGDATA 已存在且含 `PG_VERSION`,附件目录已存在
- [ ] 已证明没有两个 PostgreSQL 实例同时挂载同一 PGDATA;任何不一致均停止发布
- [ ] 已记录活动 server/db 容器、不可变镜像 ID(及可用 repo digest)、Caddy 目标、挂载源、环境摘要,不记录 env 正文
- [ ] 已冻结成功 migration 数量(当前预期 58)和关键业务表逐表计数基线
- [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"`
- [ ] 本地门禁:`./scripts/check-minimal.sh`
- [ ] 部署契约校验:`node deploy/trialrun/validate-env-contract.js`
@@ -33,23 +40,30 @@
- [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh`
- [ ] 备份(DB + 文件存储):
- [ ] `set -a && source deploy/trialrun/.env && set +a`
- [ ] `DB_TOOL_MODE=docker DB_CONTAINER=hyb-oa-trialrun-db ./scripts/backup-all.sh`
- [ ] `mapfile` 读取 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... ps -q db` 后,容器数量严格等于 1
- [ ] `DB_TOOL_MODE=docker DB_CONTAINER="$DB_CONTAINER" STORAGE_DIR_HOST=<verified-active-storage-host-directory> SOURCE_IMAGE_DIGEST=<verified-sha256-image-id> ./scripts/backup-all.sh`
- [ ] `DB_CONTAINER` 或 `STORAGE_DIR_HOST` 缺失时备份硬失败,没有回退到历史容器名或仓库默认附件目录
- [ ] manifest 至少含真实 `buildSha` 或活动 `sourceImageDigest`;旧实例无 build SHA 时未填写待部署提交
- [ ] 同一批次包含 `database.dump`、`storage.tar.gz`、`manifest.json`,两份 SHA-256 校验通过
- [ ] 完整批次已复制到获批异机/异盘,并记录保留周期
- [ ] 同批次已在隔离影子目标恢复:migration 数、关键业务表逐表计数、附件普通文件数均与冻结基线一致,兼容镜像 `/ready` 通过
- [ ] 停机复制方案已冻结:旧 PostgreSQL 完全停止后复制到空新目录并保留所有权/权限/链接/扩展属性;旧目录原位保留且观察期内不再写入
## B. 发布(Deploy)
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
- [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 发布命令显式携带批准的新 project、两个绝对数据路径和 `DEPLOY_DATA_MODE=existing`;未依赖 `.env.example` 相对默认值
- [ ] 若为首次初始化或受控应急而直接使用 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 -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口,并对该端口执行 `/ready`,未依赖当前 shell 中可能缺失的 `APP_HOST_PORT`
- [ ] `/ready` 的 JSON 在 server 容器内用 Node 核对 build SHA;宿主机无需 Node.js
- [ ] 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`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -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`,已记录为发布阻塞,不忽略通过
- [ ] external deps smoke:
- [ ] `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js`
- [ ] 若 external deps smoke 通过,已确认通过前提不是“企微关闭 + OCR mock”误通过
- [ ] 若启用腾讯 OCR,已脱敏核查 `OCR_PROVIDER=tencent`、`TENCENT_SECRET_ID` 长度、`TENCENT_SECRET_KEY` 长度和 `TENCENT_OCR_REGION`
- [ ] 若本次包含前端体验修复,已在容器内确认 `/app/client/dist` 含关键 test id 或公网 asset hash 已更新
@@ -61,7 +75,7 @@
- [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果
- [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项
- [ ] 查看 server 日志(无异常错误风暴):
- [ ] `docker logs --tail=200 hyb-oa-trialrun-server`
- [ ] `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs --tail=200 server`
- [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务
- [ ] 已确认 live smoke 仅检查 `/ready`、登录、Portal、项目、公告、个人设置和企微配置状态
- [ ] 若涉及票据/OCR,已执行真实 OCR 辅助复核或记录跳过原因:`PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 ... npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts`
@@ -71,13 +85,13 @@
> 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。
- [ ] **EXTERNAL_GATED**:若回滚目标仍包含旧认证挑战逻辑,人工确认边缘继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;脚本不会自动改边缘,未确认不得回滚开放,直到安全版本恢复
- [ ] 停止 server:`docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server`
- [ ] 停止 server:`docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server`
- [ ] 恢复 DB/存储属于获批事故操作;当前脚本仅允许 `RESTORE_SCOPE=shadow`,不得绕过后直接指向真实目标
- [ ] 已先在独立数据库和空附件目录完成同批次影子恢复,数据库名精确确认且 manifest/SHA-256 匹配
- [ ] 回到上个版本:`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 -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server`
- [ ] 已重新用 `docker-compose -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 解析实际宿主端口并执行 `/ready`
- [ ] 若仅为 OCR 应急降级,已把远端 `.env` 的 `OCR_PROVIDER` 临时切回 `mock`,重启 server,并在 Issue/发布记录中写明原因、影响范围和恢复时间
+67 -8
查看文件
@@ -8,6 +8,10 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"
POSTGRES_DATA_DIR="${POSTGRES_DATA_DIR:-}"
STORAGE_HOST_DIR="${STORAGE_HOST_DIR:-}"
DEPLOY_DATA_MODE="${DEPLOY_DATA_MODE:-}"
ENABLE_WORKER="${ENABLE_WORKER:-0}"
ENABLE_MINIO="${ENABLE_MINIO:-0}"
SYNC_ENV="${SYNC_ENV:-0}"
@@ -47,6 +51,38 @@ if [[ -z "$DEPLOY_SERVER" ]]; then
exit 1
fi
if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "[ERROR] COMPOSE_PROJECT_NAME 只能包含小写字母、数字、下划线和连字符" >&2
exit 1
fi
validate_data_path() {
local name="$1"
local value="$2"
if [[ -z "$value" || "$value" != /* || ! "$value" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
echo "[ERROR] $name 必须显式提供受限字符集的绝对路径" >&2
exit 1
fi
if [[ "$value" == "/" || "$value" == */ || "$value" == *"//"* \
|| "/$value/" == *"/../"* || "/$value/" == *"/./"* ]]; then
echo "[ERROR] $name 不得是根目录、尾随斜杠或包含空、.、.. 路径段" >&2
exit 1
fi
}
validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"
validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"
if [[ "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR" \
|| "$POSTGRES_DATA_DIR" == "$STORAGE_HOST_DIR"/* \
|| "$STORAGE_HOST_DIR" == "$POSTGRES_DATA_DIR"/* ]]; then
echo "[ERROR] PostgreSQL 与附件宿主目录必须相互独立" >&2
exit 1
fi
if [[ "$DEPLOY_DATA_MODE" != "existing" && "$DEPLOY_DATA_MODE" != "initialize" ]]; then
echo "[ERROR] DEPLOY_DATA_MODE 必须显式设为 existing 或 initialize" >&2
exit 1
fi
if [[ ! -f "${ROOT_DIR}/${PROVENANCE_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${PROVENANCE_SCRIPT}" >&2
exit 1
@@ -137,10 +173,25 @@ fi
echo ">>> 6. 在服务器执行部署与 smoke"
ssh "${DEPLOY_SERVER}" \
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE'
set -euo pipefail
cd "$REMOTE_DIR"
mkdir -p deploy/trialrun/data/postgres deploy/trialrun/data/storage deploy/trialrun/data/minio
if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then
mkdir -p "$POSTGRES_DATA_DIR" "$STORAGE_HOST_DIR"
else
if [[ ! -d "$POSTGRES_DATA_DIR" || ! -f "$POSTGRES_DATA_DIR/PG_VERSION" ]]; then
echo "[ERROR] existing 模式要求 POSTGRES_DATA_DIR 已存在且包含 PG_VERSION" >&2
exit 1
fi
if [[ ! -d "$STORAGE_HOST_DIR" ]]; then
echo "[ERROR] existing 模式要求 STORAGE_HOST_DIR 已存在" >&2
exit 1
fi
fi
if [[ "$ENABLE_MINIO" == "1" ]]; then
mkdir -p deploy/trialrun/data/minio
fi
if [[ ! -f "$REMOTE_ENV_FILE" ]]; then
echo "[ERROR] 远端缺少 $REMOTE_ENV_FILE,请先手工创建或使用 SYNC_ENV=1" >&2
@@ -148,9 +199,9 @@ if [[ ! -f "$REMOTE_ENV_FILE" ]]; then
fi
if docker compose version >/dev/null 2>&1; then
compose_cmd=(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
elif command -v docker-compose >/dev/null 2>&1; then
compose_cmd=(docker-compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE")
else
echo "[ERROR] 远端既没有 docker compose,也没有 docker-compose" >&2
exit 1
@@ -193,11 +244,19 @@ 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="$ready_payload" EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" node -e '
const payload = JSON.parse(process.env.READY_PAYLOAD || "{}");
if ready_payload="$(curl -fsS "http://127.0.0.1:${server_host_port}/ready" 2>/dev/null)"; then
if printf '%s' "$ready_payload" | "${compose_cmd[@]}" exec -T \
-e EXPECTED_BUILD_SHA="${BUILD_SHA:0:12}" server node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8") || "{}");
if (payload?.data?.buildSha !== process.env.EXPECTED_BUILD_SHA) process.exit(1);
'; then
ready_ok=1
@@ -263,4 +322,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 暴露的回环端口"
+5 -13
查看文件
@@ -3,16 +3,13 @@ version: "3.8"
services:
db:
image: postgres:16-alpine
container_name: hyb-oa-trialrun-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- ./data/postgres:/var/lib/postgresql/data
- ${POSTGRES_DATA_DIR:-./data/postgres}:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@@ -21,7 +18,6 @@ services:
minio:
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
container_name: hyb-oa-trialrun-minio
restart: unless-stopped
profiles: ["minio"]
environment:
@@ -36,7 +32,6 @@ services:
minio_init:
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
container_name: hyb-oa-trialrun-minio-init
profiles: ["minio"]
depends_on:
- minio
@@ -58,7 +53,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-server
restart: unless-stopped
depends_on:
db:
@@ -69,9 +63,9 @@ 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}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
worker:
build:
@@ -80,7 +74,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-worker
restart: unless-stopped
profiles: ["worker"]
depends_on:
@@ -95,7 +88,7 @@ services:
SERVE_CLIENT_DIST: "false"
JOBS_ENABLED: "true"
volumes:
- ./data/storage:${STORAGE_DIR}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
init:
build:
@@ -104,7 +97,6 @@ services:
args:
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
VITE_BUILD_SHA: ${VITE_BUILD_SHA:?set VITE_BUILD_SHA to the matching short commit}
container_name: hyb-oa-trialrun-init
depends_on:
db:
condition: service_healthy
@@ -114,7 +106,7 @@ services:
NODE_ENV: production
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
volumes:
- ./data/storage:${STORAGE_DIR}
- ${STORAGE_HOST_DIR:-./data/storage}:${STORAGE_DIR}
entrypoint: ["sh", "-c"]
command: >
cd /app/server
+7 -1
查看文件
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-hyb-oa-trialrun}"
SERVICE_NAME="${SERVICE_NAME:-hyb-oa-warch3-ops}"
RUN_AT="${RUN_AT:-07:30}"
MODE="${1:-install}"
@@ -37,6 +38,10 @@ install_timer() {
echo "[ERROR] 远端 env 不存在: $REMOTE_ENV_FILE" >&2
exit 1
fi
if [[ ! "$COMPOSE_PROJECT_NAME" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "[ERROR] COMPOSE_PROJECT_NAME 格式无效" >&2
exit 1
fi
local compose_exec
if docker compose version >/dev/null 2>&1; then
@@ -60,10 +65,11 @@ WorkingDirectory=${REMOTE_DIR}
Environment=REMOTE_DIR=${REMOTE_DIR}
Environment=REMOTE_ENV_FILE=${REMOTE_ENV_FILE}
Environment=COMPOSE_FILE=${COMPOSE_FILE}
Environment=COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}
Environment=LIVE_SMOKE_BASE_URL=https://oa.hyinda.com
Environment=WARCH3_SEND_ALERT=1
Environment=WARCH3_OPS_ALERT_SEND=1
ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"'
ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"'
SERVICE
cat >"$TIMER_FILE" <<TIMER
+69 -36
查看文件
@@ -6,20 +6,21 @@
- 官网继续保持 `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`
2. 手工创建 `deploy/trialrun/.env`
3. 手工更新 OA 的 `Caddyfile` 站点块并确认 TLS 正常
4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`)
4. 本地通过受控配置显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`、绝对数据目录和 `DEPLOY_DATA_MODE`,再执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`)
## 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` 为准
- 所有 Compose 命令必须显式使用同一个 `COMPOSE_PROJECT_NAME` 和 `-p`;不得依赖目录名推导项目身份,也不得写死容器名。
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
- 企业微信与 OCR 已进入公网试运行配置。核查时只检查开关、长度和 smoke 结果,不在日志、Issue 或文档中输出 secret 明文。
@@ -45,6 +46,9 @@ 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 必须指向相同值。
- `POSTGRES_DATA_DIR` 和 `STORAGE_HOST_DIR` 是宿主数据事实源。示例相对值仅供本地直接 Compose;正式 `deploy.sh` 要求在命令环境中显式提供两个绝对路径和 `DEPLOY_DATA_MODE=existing|initialize`。
- `existing` 要求 PGDATA 已存在且包含 `PG_VERSION`,附件目录也必须已存在;`initialize` 才会创建目录。生产环境更换项目名或任一数据目录只能在同一次获批停机窗口完成,禁止两个 PostgreSQL 实例同时挂载同一 PGDATA。
- 历史上已经写成带引号的 `.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 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。
@@ -64,10 +68,11 @@ cd /path/to/hyb-oa
node deploy/trialrun/validate-env-contract.js
export BUILD_SHA="<approved-full-commit>"
export VITE_BUILD_SHA="<matching-12-char-commit>"
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
export COMPOSE_PROJECT_NAME="<approved-compose-project>"
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
以上直接 Compose 命令仅用于首次初始化或受控应急;正式发布优先使用 `deploy/trialrun/deploy.sh`,由脚本从 clean、已进入 upstream 的提交自动注入两个构建标识。缺少任一标识时 Compose 必须拒绝解析。
@@ -75,7 +80,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
可选:启用 MinIO(对象存储,F21):
```bash
cd /path/to/hyb-oa
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile minio up -d minio minio_init
```
可选:启用 Worker 进程(推荐在试运行/多进程模式使用):
@@ -83,13 +88,13 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
cd /path/to/hyb-oa
# 1) 在 deploy/trialrun/.env 中设置:JOBS_ENABLED="false"
# 2) 启用 worker profile(会额外启动 worker 容器)
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env --profile worker up -d --build server worker
```
### 1.3 初始化(迁移 + 配置 seed + 试运行管理员)
```bash
cd /path/to/hyb-oa
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
```
说明:
@@ -102,10 +107,12 @@ 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
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
SERVER_HOST_PORT="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -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 -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
```
说明:
@@ -119,7 +126,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 -p "$COMPOSE_PROJECT_NAME" ... port server 3000` 返回的宿主回环端口(单域托管前端)。
### 1.4.1 腾讯 OCR 试运行核查
远端 `.env` 是 OCR 配置事实源,禁止提交或复制密钥明文。只做以下脱敏检查:
@@ -144,8 +151,8 @@ printf 'TENCENT_OCR_REGION=%s\n' "$TENCENT_OCR_REGION"
发布后 smoke:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js
```
真实 OCR 辅助复核(本地执行,不提交发票文件):
@@ -161,7 +168,7 @@ npx playwright test tests/e2e/manual/live-invoice-ocr.spec.ts --workers=1 --repo
回滚到 mock 只允许作为应急降级:
1. 在远端 `.env` 将 `OCR_PROVIDER=tencent` 改为 `OCR_PROVIDER=mock`。
2. 执行 `docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。
2. 执行 `docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d server`。
3. 在 Issue/发布记录中写明降级原因、预计恢复时间和影响范围。
## 1.5 备份与恢复演练(正式启用前必须完成)
@@ -174,12 +181,21 @@ set -a
source deploy/trialrun/.env
set +a
mapfile -t DB_CONTAINERS < <(docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env ps -q db)
test "${#DB_CONTAINERS[@]}" -eq 1
DB_CONTAINER="${DB_CONTAINERS[0]}"
SOURCE_IMAGE_DIGEST="$(docker inspect --format '{{.Image}}' <verified-active-server-container>)"
DB_TOOL_MODE=docker \
DB_CONTAINER=hyb-oa-trialrun-db \
DB_CONTAINER="$DB_CONTAINER" \
STORAGE_DIR_HOST="<verified-active-storage-host-directory>" \
SOURCE_IMAGE_DIGEST="$SOURCE_IMAGE_DIGEST" \
./scripts/backup-all.sh
```
`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。
`backup-all.sh` 可使用宿主 Node.js 或 Python 3 生成同结构 manifest;Docker-only 主机没有 Node.js 时会自动使用 Python 3,两者都没有则失败且不发布完整批次。`DB_CONTAINER` 和 `STORAGE_DIR_HOST` 均无隐式默认值:前者必须由已核验 Compose 项目的 `ps -q db` 解析,后者必须与活动 server 的附件挂载源一致。真实运行环境执行前,不得只相信可能漂移的 `.env`:先脱敏确认所选 PostgreSQL 容器的数据库/用户与至少一个活动应用连接一致,再把已验证的运行参数传给脚本;不在日志中输出参数值。
manifest 必须至少记录一个真实来源标识:活动 `/ready` 有构建标识时显式传 `BUILD_SHA`;没有时,从活动 server 容器 `docker inspect --format '{{.Image}}'` 取得 `sha256:` 镜像 ID并传 `SOURCE_IMAGE_DIGEST`。不得用待部署提交填充旧实例的 `buildSha`。
输出默认在 `deploy/trialrun/backups/hyb-oa-backup-<run-id>/`,其中包含:
@@ -222,11 +238,24 @@ RESTORE_SCOPE=shadow \
`restore-db.sh` 和 `restore-storage.sh` 默认只接受 `shadow`。影子库名必须包含独立的 `shadow`、`drill` 或 `restore_test` 标记;数据库确认不匹配、库名不安全、附件目录非空、目标为默认试运行附件目录或归档路径危险时均在写入前退出。
### 1.5.3 真实事故恢复
真实格式影子恢复的验收不仅是 `/ready`:恢复后的 `_prisma_migrations` 成功项必须等于冻结基线(当前预期 58),预先列出的关键业务表计数必须逐表一致,附件解包后的普通文件数必须与活动源目录一致。任一项不一致即判定批次不可作为切换回滚点。
### 1.5.3 生产切换前冻结记录
执行正确备份和影子恢复前,先用只读命令记录:
- `docker compose ls` 实测的旧 Compose project 名;不得按目录名推断,后续停止旧栈必须使用该实测值。
- 活动 server/db 容器 ID、活动镜像 ID(`sha256:`)及可用的 repo digest;回滚至少保留不可变镜像 ID。
- 活动 DB/附件真实宿主挂载源、Caddy 目标端口和环境文件摘要,不输出环境变量正文。
- 成功 migration 数量和固定关键表计数基线。至少覆盖组织人员、角色、采购、报销、发票池、支付批次、合同、项目和附件关联表。
活动数据复制策略在停机窗口前冻结:旧 PostgreSQL 完全停止后,把 PGDATA 和附件复制到预先确认的空绝对目录,并保留所有权、权限、链接和扩展属性;旧目录留在原位且观察期内不再挂载写入。不得移动或改名后只留下单份已迁移数据,也不得复制运行中的 PGDATA。
### 1.5.4 真实事故恢复
真实库/附件恢复不是普通脚本路径。必须另建或更新获批 Trellis task,记录停机窗口、恢复批次、实际目标、两名负责人、当前备份、回滚点和验证清单。当前恢复脚本不会接受 `production` 模式,不得通过修改环境变量或脚本临时绕过。恢复后必须验证 `/ready`、运行 commit、核心权限和 DB/附件关联完整性。
### 1.5.4 小团队上线前演练记录
### 1.5.5 小团队上线前演练记录
上线前至少记录一次演练结果:
- 演练日期:
@@ -264,28 +293,31 @@ RESTORE_SCOPE=shadow \
步骤:
1) 先做备份(见 E14 脚本)
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> POSTGRES_DATA_DIR=<absolute-pgdata> STORAGE_HOST_DIR=<absolute-storage> DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
- 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。
- 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
3) 构建并重启:
```bash
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 init
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
```
### 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 -p "$COMPOSE_PROJECT_NAME" -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
# 3) 在容器内查构建产物是否包含关键 test id
docker exec hyb-oa-trialrun-server sh -lc \
# 3) 在当前 Compose 项目的 server 容器内查构建产物是否包含关键 test id
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server sh -lc \
'for pat in invoice-pool-mobile-list reimbursements-mobile-list mobile-capture-file multiple:!0; do printf "%s " "$pat"; grep -Rao "$pat" /app/client/dist/assets /app/client/dist/index.html 2>/dev/null | wc -l; done'
```
@@ -293,9 +325,10 @@ docker exec hyb-oa-trialrun-server sh -lc \
```bash
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
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm -T init </dev/null
SERVER_HOST_PORT="$(docker-compose -p "$COMPOSE_PROJECT_NAME" -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"
```
重建后必须补做:
@@ -313,13 +346,13 @@ curl -fsS http://127.0.0.1:3000/ready
```bash
# 远端目录不是 git 工作树,推荐在本地切到目标 tag 后重新执行 deploy.sh
# git checkout <previous-tag>
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
### 3.2 需要 DB 回滚(推荐做法)
1) 停止服务:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server
```
2) 按 E14 恢复 DB + 文件存储备份
3) 启动服务并验收 `/ready` 与登录
@@ -327,7 +360,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
## 4. 日志与排障
- server 容器日志:
```bash
docker logs -f hyb-oa-trialrun-server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env logs -f server
```
- `/ready` 失败通常为 DB 不可达或连接串错误。
@@ -448,7 +481,7 @@ npm run test:e2e -- tests/e2e/manual/live-invoice-ocr-batch.spec.ts
- `OPENAPI_DOCS_ENABLED=false`
3. 重新执行:
```bash
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
```
4. 验证浏览器和企业微信 WebView 都能正常登录、刷新 token 与退出登录。
+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"
+70 -4
查看文件
@@ -4,10 +4,21 @@ const path = require('node:path');
const rootDir = path.resolve(__dirname, '..', '..');
const configPath = path.join(rootDir, 'server/src/config.js');
const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example');
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
const envExamplePath = path.resolve(
process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'),
);
const composePath = path.resolve(
process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'),
);
const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md');
const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md');
const deployScriptPath = path.resolve(
process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'),
);
const timerScriptPath = path.resolve(
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|| path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'),
);
const nginxPaths = [
path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'),
path.join(rootDir, 'deploy/trialrun/nginx.conf.example'),
@@ -130,6 +141,8 @@ const envExampleContent = readFile(envExamplePath);
const composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
const deployScriptContent = readFile(deployScriptPath);
const timerScriptContent = readFile(timerScriptPath);
const nginxContents = nginxPaths.map((filePath) => ({
filePath,
content: readFile(filePath),
@@ -150,6 +163,15 @@ for (const key of deploymentInjectedEnvKeys) {
}
}
for (const key of ['APP_HOST_PORT', 'POSTGRES_DATA_DIR', 'STORAGE_HOST_DIR']) {
if (!envExampleKeys.has(key)) {
errors.push(`.env.example 缺少 Compose 宿主契约键 ${key}`);
}
}
if (envExampleKeys.has('COMPOSE_PROJECT_NAME')) {
errors.push('.env.example 不应持久化 COMPOSE_PROJECT_NAME,调用方必须通过 -p 显式选择项目');
}
for (const serviceName of requiredServices) {
const block = extractServiceBlock(composeContent, serviceName);
if (!block) {
@@ -185,8 +207,52 @@ 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 端口只绑定到宿主回环地址');
}
const dbBlock = extractServiceBlock(composeContent, 'db');
if (!dbBlock) {
errors.push('docker-compose.yml 缺少服务 db');
} else {
if (/^\s{4}ports:\s*$/m.test(dbBlock)) {
errors.push('服务 db 不得向宿主发布端口');
}
if (!/\$\{POSTGRES_DATA_DIR:-\.\/data\/postgres\}:\/var\/lib\/postgresql\/data/.test(dbBlock)) {
errors.push('服务 db 必须通过 POSTGRES_DATA_DIR 参数化宿主数据目录');
}
}
if (/^\s+container_name:\s*/m.test(composeContent)) {
errors.push('docker-compose.yml 不得固定 container_name,容器身份必须由 Compose project 隔离');
}
for (const serviceName of ['server', 'worker', 'init']) {
const block = extractServiceBlock(composeContent, serviceName);
if (block && !/\$\{STORAGE_HOST_DIR:-\.\/data\/storage\}:\$\{STORAGE_DIR\}/.test(block)) {
errors.push(`服务 ${serviceName} 必须通过 STORAGE_HOST_DIR 参数化宿主附件目录`);
}
}
for (const [name, content] of [
['deploy.sh', deployScriptContent],
['install-warch3-ops-timer.sh', timerScriptContent],
]) {
if (!content.includes('-p "$COMPOSE_PROJECT_NAME"')) {
errors.push(`${name} 必须向 Compose 显式传入独立 project name`);
}
}
for (const requiredFragment of [
'validate_data_path POSTGRES_DATA_DIR "$POSTGRES_DATA_DIR"',
'validate_data_path STORAGE_HOST_DIR "$STORAGE_HOST_DIR"',
'DEPLOY_DATA_MODE" != "existing"',
'DEPLOY_DATA_MODE" != "initialize"',
'"$POSTGRES_DATA_DIR/PG_VERSION"',
]) {
if (!deployScriptContent.includes(requiredFragment)) {
errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`);
}
}
for (const { filePath, content } of nginxContents) {
+11 -1
查看文件
@@ -34,13 +34,23 @@ LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readine
标准部署命令:
```bash
DEPLOY_SERVER=<ssh-target> \
REMOTE_DIR=<remote-dir> \
COMPOSE_PROJECT_NAME=<approved-project> \
POSTGRES_DATA_DIR=<absolute-pgdata> \
STORAGE_HOST_DIR=<absolute-storage> \
DEPLOY_DATA_MODE=existing \
bash deploy/trialrun/deploy.sh
```
默认不覆盖远端 `.env`。只有确认本地 `deploy/trialrun/.env` 可直接上服务器时,才允许在单独确认后使用:
```bash
SYNC_ENV=1 bash deploy/trialrun/deploy.sh
SYNC_ENV=1 \
POSTGRES_DATA_DIR=<absolute-pgdata> \
STORAGE_HOST_DIR=<absolute-storage> \
DEPLOY_DATA_MODE=existing \
bash deploy/trialrun/deploy.sh
```
部署完成后必须记录:
+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。
+25 -3
查看文件
@@ -4,12 +4,32 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-$ROOT_DIR/deploy/trialrun/backups}"
BACKUP_RUN_ID="${BACKUP_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)}"
BUILD_SHA="${BUILD_SHA:-}"
SOURCE_IMAGE_DIGEST="${SOURCE_IMAGE_DIGEST:-}"
if [[ ! "$BACKUP_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$ ]]; then
echo "[ERR] BACKUP_RUN_ID must use 1-80 safe characters" >&2
exit 1
fi
if [[ -z "$BUILD_SHA" ]]; then
BUILD_SHA="$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || true)"
fi
if [[ -n "$BUILD_SHA" && ! "$BUILD_SHA" =~ ^[0-9a-fA-F]{7,64}$ ]]; then
echo "[ERR] BUILD_SHA must be a 7-64 character hexadecimal commit id" >&2
exit 1
fi
if [[ -n "$SOURCE_IMAGE_DIGEST" && ! "$SOURCE_IMAGE_DIGEST" =~ ^sha256:[0-9a-fA-F]{64}$ ]]; then
echo "[ERR] SOURCE_IMAGE_DIGEST must use sha256:<64 hexadecimal characters>" >&2
exit 1
fi
if [[ -z "$BUILD_SHA" && -z "$SOURCE_IMAGE_DIGEST" ]]; then
echo "[ERR] BUILD_SHA or SOURCE_IMAGE_DIGEST is required to identify the backup source" >&2
exit 1
fi
BUILD_SHA="${BUILD_SHA,,}"
SOURCE_IMAGE_DIGEST="${SOURCE_IMAGE_DIGEST,,}"
mkdir -p "$BACKUP_DIR"
FINAL_DIR="$BACKUP_DIR/hyb-oa-backup-$BACKUP_RUN_ID"
if [[ -e "$FINAL_DIR" ]]; then
@@ -64,23 +84,23 @@ DB_SHA256="$(sha256sum "$DB_FILE" | awk '{print $1}')"
STORAGE_SHA256="$(sha256sum "$STORAGE_FILE" | awk '{print $1}')"
DB_BYTES="$(wc -c <"$DB_FILE" | tr -d ' ')"
STORAGE_BYTES="$(wc -c <"$STORAGE_FILE" | tr -d ' ')"
BUILD_SHA="$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || true)"
CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
MANIFEST_ARGS=(
"$TEMP_DIR/$MANIFEST_NAME" "$BACKUP_RUN_ID" "$CREATED_AT" "$BUILD_SHA"
"$TEMP_DIR/$MANIFEST_NAME" "$BACKUP_RUN_ID" "$CREATED_AT" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST"
"$DB_NAME" "$DB_BYTES" "$DB_SHA256" "$STORAGE_NAME" "$STORAGE_BYTES" "$STORAGE_SHA256"
)
if command -v node >/dev/null 2>&1; then
node - "${MANIFEST_ARGS[@]}" <<'NODE'
const fs = require("node:fs");
const [manifestPath, runId, createdAt, buildSha, dbFile, dbBytes, dbSha256, storageFile, storageBytes, storageSha256] = process.argv.slice(2);
const [manifestPath, runId, createdAt, buildSha, sourceImageDigest, dbFile, dbBytes, dbSha256, storageFile, storageBytes, storageSha256] = process.argv.slice(2);
const manifest = {
schemaVersion: 1,
runId,
createdAt,
buildSha: buildSha || null,
sourceImageDigest: sourceImageDigest || null,
status: "complete",
artifacts: {
database: { file: dbFile, bytes: Number(dbBytes), sha256: dbSha256 },
@@ -99,6 +119,7 @@ import sys
run_id,
created_at,
build_sha,
source_image_digest,
db_file,
db_bytes,
db_sha256,
@@ -112,6 +133,7 @@ manifest = {
"runId": run_id,
"createdAt": created_at,
"buildSha": build_sha or None,
"sourceImageDigest": source_image_digest or None,
"status": "complete",
"artifacts": {
"database": {"file": db_file, "bytes": int(db_bytes), "sha256": db_sha256},
+6 -1
查看文件
@@ -56,11 +56,16 @@ if ! command -v docker >/dev/null 2>&1; then
exit 1
fi
DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}"
DB_CONTAINER="${DB_CONTAINER:-}"
POSTGRES_DB="${POSTGRES_DB:-}"
POSTGRES_USER="${POSTGRES_USER:-}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
if [[ -z "$DB_CONTAINER" ]]; then
echo "[ERR] Using docker fallback requires explicit DB_CONTAINER" >&2
exit 1
fi
if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then
echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2
exit 1
+2 -8
查看文件
@@ -16,14 +16,8 @@ fi
STORAGE_DIR_HOST="${STORAGE_DIR_HOST:-}"
if [[ -z "$STORAGE_DIR_HOST" ]]; then
if [[ -n "${STORAGE_DIR:-}" && -d "${STORAGE_DIR:-}" ]]; then
STORAGE_DIR_HOST="$STORAGE_DIR"
elif [[ -d "$ROOT_DIR/deploy/trialrun/data/storage" ]]; then
STORAGE_DIR_HOST="$ROOT_DIR/deploy/trialrun/data/storage"
else
echo "[ERR] storage dir not found; set STORAGE_DIR_HOST" >&2
exit 1
fi
echo "[ERR] STORAGE_DIR_HOST is required; implicit storage paths are forbidden" >&2
exit 1
fi
if [[ ! -d "$STORAGE_DIR_HOST" ]]; then
+88 -4
查看文件
@@ -41,6 +41,79 @@ mkdir -p "$SOURCE_STORAGE/nested" "$NONEMPTY_STORAGE" "$BACKUP_ROOT"
printf 'synthetic attachment for restore drill\n' >"$SOURCE_STORAGE/nested/marker.txt"
printf 'must not be overwritten\n' >"$NONEMPTY_STORAGE/existing.txt"
if BUILD_SHA=not-a-commit SOURCE_IMAGE_DIGEST= \
BACKUP_DIR="$BACKUP_ROOT" BACKUP_RUN_ID=invalid-source \
"$ROOT_DIR/scripts/backup-all.sh" >/dev/null 2>&1; then
echo "[ERR] invalid backup source identity was accepted" >&2
exit 1
fi
if env -u STORAGE_DIR_HOST -u STORAGE_DIR \
OUT_FILE="$DRILL_DIR/implicit-storage.tar.gz" \
"$ROOT_DIR/scripts/backup-storage.sh" >/dev/null 2>&1; then
echo "[ERR] implicit storage backup source was accepted" >&2
exit 1
fi
if env -u DB_CONTAINER DB_TOOL_MODE=docker DATABASE_URL=postgresql://invalid \
POSTGRES_DB=invalid POSTGRES_USER=invalid POSTGRES_PASSWORD=invalid \
OUT_FILE="$DRILL_DIR/implicit-db.dump" \
"$ROOT_DIR/scripts/backup-db.sh" >/dev/null 2>&1; then
echo "[ERR] implicit DB container was accepted" >&2
exit 1
fi
if command -v node >/dev/null 2>&1; then
if deploy_error="$(DEPLOY_SERVER=unused POSTGRES_DATA_DIR=./relative \
STORAGE_HOST_DIR=/srv/hyb-oa/storage DEPLOY_DATA_MODE=existing \
"$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then
echo "[ERR] formal deploy accepted a relative PostgreSQL data path" >&2
exit 1
fi
if [[ "$deploy_error" != *"POSTGRES_DATA_DIR"* ]]; then
echo "[ERR] relative deploy path did not fail at the data-path guard" >&2
exit 1
fi
if deploy_error="$(DEPLOY_SERVER=unused POSTGRES_DATA_DIR=/srv/hyb-oa/data \
STORAGE_HOST_DIR=/srv/hyb-oa/data DEPLOY_DATA_MODE=existing \
"$ROOT_DIR/deploy/trialrun/deploy.sh" 2>&1)"; then
echo "[ERR] formal deploy accepted overlapping data paths" >&2
exit 1
fi
if [[ "$deploy_error" != *"必须相互独立"* ]]; then
echo "[ERR] overlapping deploy paths did not fail at the isolation guard" >&2
exit 1
fi
MUTATED_PORT_COMPOSE="$DRILL_DIR/docker-compose-db-port.yml"
sed '/ db:/a\ ports:\n - "5432:5432"' \
"$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_PORT_COMPOSE"
if ENV_CONTRACT_COMPOSE_PATH="$MUTATED_PORT_COMPOSE" \
node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then
echo "[ERR] unsafe DB port publication passed the contract" >&2
exit 1
fi
MUTATED_NAME_COMPOSE="$DRILL_DIR/docker-compose-fixed-name.yml"
sed '/ db:/a\ container_name: forbidden-fixed-name' \
"$ROOT_DIR/deploy/trialrun/docker-compose.yml" >"$MUTATED_NAME_COMPOSE"
if ENV_CONTRACT_COMPOSE_PATH="$MUTATED_NAME_COMPOSE" \
node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then
echo "[ERR] fixed Compose container name passed the contract" >&2
exit 1
fi
MUTATED_DEPLOY="$DRILL_DIR/deploy-without-project.sh"
sed 's/-p "$COMPOSE_PROJECT_NAME" //g' \
"$ROOT_DIR/deploy/trialrun/deploy.sh" >"$MUTATED_DEPLOY"
if ENV_CONTRACT_DEPLOY_SCRIPT_PATH="$MUTATED_DEPLOY" \
node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js" >/dev/null 2>&1; then
echo "[ERR] deployment without an explicit Compose project passed the contract" >&2
exit 1
fi
fi
docker run -d --rm --name "$CONTAINER_NAME" \
-e "POSTGRES_USER=$POSTGRES_USER" \
-e "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" \
@@ -59,6 +132,12 @@ HOST_PORT="$(docker port "$CONTAINER_NAME" 5432/tcp | awk -F: 'NR==1 {print $NF}
DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@127.0.0.1:$HOST_PORT/$POSTGRES_DB"
export DATABASE_URL STORAGE_DIR_HOST="$SOURCE_STORAGE" BACKUP_DIR="$BACKUP_ROOT" BACKUP_RUN_ID="$DRILL_RUN_ID"
export DB_TOOL_MODE="docker" DB_CONTAINER="$CONTAINER_NAME" POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB
if [[ "${DRILL_SOURCE_ID_MODE:-both}" == "digest_only" ]]; then
export BUILD_SHA=""
else
export BUILD_SHA="abcdef1234567890abcdef1234567890abcdef12"
fi
export SOURCE_IMAGE_DIGEST="sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -qc 'CREATE TABLE restore_drill_marker (value text NOT NULL)'
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -qc "INSERT INTO restore_drill_marker(value) VALUES ('synthetic-round-trip')"
@@ -69,14 +148,17 @@ DB_BACKUP="$BATCH_DIR/database.dump"
STORAGE_BACKUP="$BATCH_DIR/storage.tar.gz"
if command -v node >/dev/null 2>&1; then
node - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" <<'NODE'
node - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST" <<'NODE'
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const [manifestPath, dbPath, storagePath] = process.argv.slice(2);
const [manifestPath, dbPath, storagePath, expectedBuildSha, expectedImageDigest] = process.argv.slice(2);
const manifestText = fs.readFileSync(manifestPath, "utf8");
const manifest = JSON.parse(manifestText);
if (manifest.status !== "complete" || manifest.schemaVersion !== 1) throw new Error("manifest contract mismatch");
if (manifest.buildSha !== (expectedBuildSha || null) || manifest.sourceImageDigest !== expectedImageDigest) {
throw new Error("manifest source identity mismatch");
}
if (path.isAbsolute(manifest.artifacts.database.file) || path.isAbsolute(manifest.artifacts.storage.file)) {
throw new Error("manifest contains absolute artifact path");
}
@@ -86,19 +168,21 @@ if (sha256(dbPath) !== manifest.artifacts.database.sha256) throw new Error("data
if (sha256(storagePath) !== manifest.artifacts.storage.sha256) throw new Error("storage checksum mismatch");
NODE
else
python3 - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" <<'PYTHON'
python3 - "$BATCH_DIR/manifest.json" "$DB_BACKUP" "$STORAGE_BACKUP" "$BUILD_SHA" "$SOURCE_IMAGE_DIGEST" <<'PYTHON'
import hashlib
import json
import os
import re
import sys
manifest_path, db_path, storage_path = sys.argv[1:]
manifest_path, db_path, storage_path, expected_build_sha, expected_image_digest = sys.argv[1:]
with open(manifest_path, encoding="utf-8") as manifest_file:
manifest_text = manifest_file.read()
manifest = json.loads(manifest_text)
if manifest.get("status") != "complete" or manifest.get("schemaVersion") != 1:
raise RuntimeError("manifest contract mismatch")
if manifest.get("buildSha") != (expected_build_sha or None) or manifest.get("sourceImageDigest") != expected_image_digest:
raise RuntimeError("manifest source identity mismatch")
for artifact in (manifest["artifacts"]["database"], manifest["artifacts"]["storage"]):
if os.path.isabs(artifact["file"]):
raise RuntimeError("manifest contains absolute artifact path")
+1
查看文件
@@ -29,6 +29,7 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|pr
PROJECT_WIDE_SKIP_BUSINESS_FLOW=1 跳过业务流 E2E
PROJECT_WIDE_SKIP_VISUAL=1 跳过项目级视觉 E2E
DEPLOY_SERVER / REMOTE_DIR / REMOTE_ENV_FILE / SYNC_ENV
COMPOSE_PROJECT_NAME / POSTGRES_DATA_DIR / STORAGE_HOST_DIR / DEPLOY_DATA_MODE
USAGE
}
+6 -1
查看文件
@@ -83,11 +83,16 @@ if ! command -v docker >/dev/null 2>&1; then
exit 1
fi
DB_CONTAINER="${DB_CONTAINER:-hyb-oa-trialrun-db}"
DB_CONTAINER="${DB_CONTAINER:-}"
POSTGRES_DB="${POSTGRES_DB:-}"
POSTGRES_USER="${POSTGRES_USER:-}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
if [[ -z "$DB_CONTAINER" ]]; then
echo "[ERR] Using docker fallback requires explicit DB_CONTAINER" >&2
exit 1
fi
if [[ -z "$POSTGRES_DB" || -z "$POSTGRES_USER" || -z "$POSTGRES_PASSWORD" ]]; then
echo "[ERR] Using docker fallback requires POSTGRES_DB/POSTGRES_USER/POSTGRES_PASSWORD" >&2
exit 1
+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" }),
});
});
});