比较提交

...
3 次代码提交
作者 SHA1 备注 提交日期
wangsli aa6b9bc373 完善报销通知与付款进度(08-01-reimbursement-notification-progress-clarity)
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-08-01 21:06:31 +08:00
wangsli ca24479a44 建立生产密钥恢复合同(08-01-production-secret-recovery-env-externalization) 2026-08-01 19:18:54 +08:00
wangsli 07de198b51 加固既有生产部署流程(07-27-yang-jun-reimbursement-readiness) 2026-08-01 18:25:20 +08:00
共修改 46 个文件,包含 1832 行新增和 130 行删除
+111 -5
查看文件
@@ -138,9 +138,12 @@ if (!hasPermission(actor, "payment.batch.review")
workflow stages use the actual pending assignee when available. Future stages
keep `actorName=null`; never guess a person from today's role holders.
- A reimbursement approved without a batch makes `payment_batch_review` the
current stage with `detail="待财务创建付款批次"`. A draft batch says
`所在付款批次待非制单人复核`; shared-batch wording must not imply that this
reimbursement alone is being reviewed again.
current stage with `detail="待财务安排付款"`. A draft batch says
`待非制单人复核付款`; employee-facing wording does not expose the internal
batch object or imply that this reimbursement alone is being reviewed again.
- The final stage label is `付款完成`, and its pending/current/failed detail
explicitly says it is a finance registration step rather than an approval.
Internal finance pages may continue to use the precise `付款批次` term.
- Missing workflow, batch or receipt facts degrade only their stages. Earlier
known stages remain visible; missing downstream objects are normal, not 5xx.
- `targetHref` is returned only when the viewer already has one of the existing
@@ -163,7 +166,7 @@ if (!hasPermission(actor, "payment.batch.review")
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
completed or exception states without changing the batch.
- Executed payment + zero active receipts -> completed payment with
`付款已完成,回单待留存`; positive count -> `付款与回单已留存`.
`付款已完成,回单待留存`; positive count -> `付款和回单已留存`.
- Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for
the reimbursement detail itself.
- Database/query failure -> existing 5xx behavior and server logging; do not
@@ -171,7 +174,7 @@ if (!hasPermission(actor, "payment.batch.review")
### 5. Good/Base/Bad Cases
- Good: `财务复核=通过` followed by current `待财务创建付款批次`, so the owner
- Good: `财务复核=通过` followed by current `待财务安排付款`, so the owner
can diagnose the exact responsibility without asking three people.
- Base: a newly submitted reimbursement shows known submission, every real
configured approval task, and the two future payment stages with no guessed
@@ -210,3 +213,106 @@ const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
```
## Scenario: Payment Responsibility Notifications
### 1. Scope / Trigger
- Trigger: creating a reimbursement payment batch, completing its maker-checker
review, or extending notifications to another payment responsibility handoff.
- These are action-required notifications, not new workflow approval tasks and
not public business-domain events.
### 2. Signatures
- Notification types: `payment_batch.review_requested` and
`payment_batch.execution_requested`.
- Both use `targetType=payment_batch`, `targetId=<batch id>` and
`targetPath=/payment-batches?id=<batch id>`.
- Both resolve to the existing `reimbursement` IM domain while payment batches
contain only reimbursement entities.
### 3. Contracts
- Batch creation resolves active employees whose roles include `manager|ceo`
and whose effective role permissions include `payment.batch.review`; exclude
the maker and never hard-code a person's name or employee ID.
- Review completion first targets the active maker when that employee still has
`payment.batch.manage`. If unavailable, fall back to other active
`finance|finance_mgr` employees with that permission; always exclude the
reviewer.
- Create station notification records inside the same transaction as the batch
creation/review state change. Dispatch through the shared IM dispatcher only
after commit. A dispatch failure is logged and, once a channel attempt starts,
retained as delivery evidence; it never rolls back the committed payment
action.
- Payment execution keeps the existing `reimbursement.paid` result notification
to the owner/submitter. `付款完成` remains a finance action and must not be
represented as another approval.
- PC and mobile approval histories use the shared
`client/src/lib/approvalDisplay.ts#workflowActionLabel`; `start|submit` both
render as `提交`, and pages must not keep local label maps.
### 4. Validation & Error Matrix
- No eligible non-maker reviewer -> create the batch with zero review
notifications; do not weaken maker-checker or notify the maker.
- Maker inactive or missing `payment.batch.manage` after review -> notify the
eligible finance fallback; no eligible fallback -> review remains committed
with zero execution notifications.
- Maker attempts review or a reviewed batch is reviewed again -> existing
deterministic `409 VALID_001`; zero responsibility notifications and zero IM
dispatch.
- IM disabled, recipient push disabled/unbound, or no matching channel -> station
notification remains; dispatcher returns a fixed skipped reason without
rolling back the payment action.
- Notification record creation fails inside the transaction -> the protected
payment state change fails atomically; no partial handoff is reported.
### 5. Good/Base/Bad Cases
- Good: finance creates one weekly batch, the eligible non-maker reviewer gets
one notification, then successful review returns the action to the active
maker for payment and receipt registration.
- Base: no eligible reviewer is configured; the draft batch remains visible for
diagnosis and no unqualified management broadcast is fabricated.
- Bad: notify every finance/manager account, let the maker review their own
batch, send before transaction commit, or label payment completion as an
approval.
### 6. Tests Required
- Route tests assert qualified reviewer resolution, maker exclusion, active
maker preference, inactive-maker finance fallback and reviewer exclusion.
- Negative tests assert maker conflict, repeated review and duplicate batch
creation produce no notification/dispatch.
- Dispatcher tests assert both types are IM-supported and resolve to the
reimbursement domain; dispatch failure must leave the committed batch state.
- PC/mobile E2E asserts `start` renders `提交` from the shared label map and the
five-stage employee projection ends with `付款复核` / `付款完成(非审批)`.
### 7. Wrong vs Correct
#### Wrong
```js
notifyEmployeesByRole(["manager", "finance"]);
await tx.paymentBatch.update({ data: { status: "reviewed" } });
```
#### Correct
```js
const recipients = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: actor.id,
});
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds: recipients,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
targetType: "payment_batch",
targetId: batch.id,
});
// after commit
await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] });
```
+123 -1
查看文件
@@ -7,7 +7,7 @@ operations require explicit approval and evidence.
## Rules
- 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`.
- Existing installations use `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh` only when deployment is approved; the wrapper reads the frozen allowlisted contract and keeps `SYNC_ENV=0`. First initialization or controlled recovery without that unit uses `deploy.sh` with explicit absolute data paths and `DEPLOY_DATA_MODE`; neither path overwrites remote `.env` by default.
- 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
@@ -1282,3 +1282,125 @@ bash scripts/qa/run-automatic-backup-contract.sh
<explicit-controlled-paths> \
bash deploy/trialrun/install-offhost-app-health-monitor.sh install
```
## Scenario: Existing Deployment Preflight And Cache Stability
### 1. Scope / Trigger
- Trigger: deploying a new published commit onto an existing single-host trialrun installation.
- Purpose: prevent a wrong repository-root interpretation from reaching `rsync --delete`, and prevent per-commit build identity from invalidating stable dependency layers.
### 2. Signatures
- Existing deployment entry: `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`.
- Remote preflight: `deploy/trialrun/preflight-remote.sh` executed over SSH before rsync.
- Canonical parameter source: allowlisted `Environment=` values from `hyb-oa-automatic-backup-daily.service`.
- Stable image stages: `base`, `client_deps`, `client_build`, `server_deps`, `server_build`.
### 3. Contracts
- Never derive `REMOTE_DIR` from Compose `com.docker.compose.project.working_dir`; it may name the Compose-file directory rather than the repository root.
- The existing-deployment wrapper reads only `REMOTE_DIR`, `REMOTE_ENV_FILE`, `COMPOSE_FILE`, `COMPOSE_PROJECT_NAME`, `POSTGRES_DATA_DIR` and `STORAGE_HOST_DIR`; it does not echo their values.
- Before `mkdir` or rsync, existing mode requires repository markers, `PG_VERSION`, storage directory and a regular non-symlink env file when `SYNC_ENV=0`.
- `apk add` and both lockfile `npm ci` steps precede `BUILD_SHA` / `VITE_BUILD_SHA`. Build identity may invalidate application output, never stable dependency installation.
- Seed and smoke output reports readiness, counts and booleans; it does not print full login identifiers, contact values, WeCom identifiers, redirect URI or generated credentials.
### 4. Validation & Error Matrix
- Config directory passed as `REMOTE_DIR` -> preflight fails before rsync.
- Remote env missing or symlinked with `SYNC_ENV=0` -> preflight fails before rsync.
- Existing PGDATA missing `PG_VERSION` or storage missing -> preflight fails before rsync.
- Canonical systemd unit missing an allowlisted value -> wrapper fails without calling `deploy.sh`.
- Lockfile unchanged but a new SHA reruns dependency installation -> Dockerfile contract test fails.
### 5. Good/Base/Bad Cases
- Good: wrapper reads the frozen unit contract, preflight passes, Git archive syncs and unchanged dependency layers are cached.
- Base: first initialization uses reviewed explicit parameters and `DEPLOY_DATA_MODE=initialize`; it does not pretend an existing unit is available.
- Bad: inspect a running container's `working_dir`, append another `deploy/trialrun`, then discover the mistake only after rsync.
### 6. Tests Required
- `bash -n deploy/trialrun/deploy.sh deploy/trialrun/deploy-existing.sh deploy/trialrun/preflight-remote.sh`.
- `node deploy/trialrun/validate-env-contract.js`.
- `cd server && npm test -- tests/unit/scripts/deployment-safety-contract.test.js`.
- Synthetic fixtures must prove wrong root, missing/symlink env and invalid data fail; static ordering must prove preflight precedes rsync and dependency stages precede build args.
### 7. Wrong vs Correct
#### Wrong
```bash
# working_dir may be the Compose config directory, not the repository root.
REMOTE_DIR="$(docker inspect <server> --format '<working_dir-label>')" \
bash deploy/trialrun/deploy.sh
```
#### Correct
```bash
DEPLOY_SERVER=<approved-ssh-target> \
bash deploy/trialrun/deploy-existing.sh
```
## Scenario: Environment Recovery Classification And Secret Custody
### 1. Scope / Trigger
- Trigger: adding/removing a trialrun environment key, documenting host-loss recovery, or preparing an environment-file migration.
- Purpose: make environment loss recoverable without placing Secret values in Git, ordinary backup, logs or AI context.
### 2. Signatures
- Template contract: `deploy/trialrun/.env.example`.
- Recovery classification: `deploy/trialrun/env-recovery-classification.json`.
- Recovery card: the opening section of `deploy/trialrun/runbook.md`.
- Validator: `node deploy/trialrun/validate-env-contract.js`.
- Fixed categories: `template_rebuild`, `external_reissue`, `direct_rotate`, `downtime_reset`, `preserve_original`.
### 3. Contracts
- Every `.env.example` key appears in exactly one fixed recovery category; the classification file contains key names only, never values, defaults, hashes, accounts or platform addresses.
- `template_rebuild` comes from reviewed templates/runbooks; `external_reissue` comes from the owning platform; `direct_rotate` records the invalidation consequence; `downtime_reset` requires an isolated approved outage.
- `POSTGRES_PASSWORD` is a reset case. PostgreSQL stores a verifier in `pg_authid`, not recoverable plaintext. Any temporary authentication relaxation is local-socket only, never a trusted host rule, and the original authentication file must be restored byte-for-byte before reopening the application.
- `FIELD_ENCRYPTION_KEY` is `preserve_original` and the highest-risk class. Its current AES-256-GCM payload is `v1:<iv>:<tag>:<ciphertext>` without a key id; replacing a lost key does not recover existing bank, identity or notification-channel ciphertext.
- `preserve_original` custody must be available to both the system administrator and a company responsible person through a repository-external shared vault or encrypted sealed copy. Evidence records roles and status only.
- Original-key custody is a current recovery constraint, not a permanent rotation design. Key id, keyring, lazy/bulk re-encryption and old-key retirement require a separate approved security task.
- Database/attachment backups do not absorb plaintext environment files. Phase 1 recovery-contract work never reads production env or contacts production systems.
### 4. Validation & Error Matrix
- New `.env.example` key missing from classification -> validator fails.
- Key appears in two categories -> validator fails and names the duplicated key.
- Unknown category, extra key, illegal key name or value-like entry -> validator fails.
- `FIELD_ENCRYPTION_KEY` outside `preserve_original` -> validator fails.
- `POSTGRES_PASSWORD` outside `downtime_reset` -> validator fails.
- Original encryption key unavailable -> stop recovery and report ciphertext impact; never generate a replacement and claim success.
- Temporary database trust remains or a DB host port is published -> recovery fails and the external entry remains closed.
### 5. Good/Base/Bad Cases
- Good: a TTL is rebuilt from the template, a WeCom Secret is reissued by its platform owner, JWT is rotated with session invalidation, and the field-encryption key is restored from dual-access custody.
- Base: the production key set differs from the example contract; a separately approved read-only check explains key names/counts without reading values before migration.
- Bad: put `.env` into the paired data backup, print a secret hash as evidence, assume `pg_authid` reveals the old password, or replace `FIELD_ENCRYPTION_KEY` with a new random value.
### 6. Tests Required
- `node deploy/trialrun/validate-env-contract.js`.
- Focused unit tests must cover the complete contract plus missing, duplicate, extra, unknown-category, illegal/value-like, field-key and database-password misclassification cases.
- Run `node scripts/qa/check-sensitive-content.mjs`, Trellis governance, process contract, docs registry audit and `git diff --check` after classification/runbook changes.
- Production key-name comparison, shared-vault custody, env migration, systemd updates, restarts and old-copy deletion remain separately approved Phase 2 evidence.
### 7. Wrong vs Correct
#### Wrong
```json
{"direct_rotate":["FIELD_ENCRYPTION_KEY"]}
```
#### Correct
```json
{"preserve_original":["FIELD_ENCRYPTION_KEY"]}
```
@@ -298,3 +298,29 @@ processingProgress.stages[] = {
- `displayFormat.ts` 新增可接受 `string | number | null | undefined` 的人民币格式化函数;PC/移动详情删除各自金额 formatter,统一复用。列表页本轮不做横向重构,避免扩大已批准范围。
- 移动材料区继续承接发票添加;固定底栏删除同名次级按钮,只渲染提交主动作。固定栏的 safe-area、loading、禁用和提交校验保持不变。
- 回退只需还原前端提交;不涉及 API、数据库、权限、workflow、生产配置或真实数据。
## 14. 生产部署防重复事故合同
### 14.1 根因与边界
- 正确发布提交 `5dac336` 前,首次尝试把 Compose `working_dir` 误当仓库根目录。该标签实际指向 `deploy/trialrun` 配置目录;现有 `deploy.sh` 又到 rsync 之后才检查远端 `.env`,因此错误目标先被同步,随后才因重复的 env 路径停止。旧容器未重建,但远端 `.env` 需从仍运行容器的项目允许键原子恢复。
- 发布慢的主因不是 Vite;现场 Vite 生产构建约 11 秒,而 server `npm ci` 约 8 分钟。Dockerfile 在依赖层之前注入每次变化的 build args,使 `apk add` 与两套依赖层随提交 SHA 一起失效。
- 当前加固只改部署脚本、Dockerfile、脱敏输出、测试和既有 runbook/spec;不修改数据库、业务状态、生产 `.env` 值或真实报销数据。加固完成后需另行批准才再次部署。
### 14.2 同步前只读 preflight
- `DEPLOY_DATA_MODE=existing` 时,`deploy.sh` 在创建目录或 rsync 前通过 SSH 验证:`REMOTE_DIR` 已存在且同时含 `server/package.json` 与 `deploy/trialrun/docker-compose.yml`;PGDATA 含 `PG_VERSION`;附件目录存在。
- `SYNC_ENV=0` 时,同一 preflight 必须确认 `REMOTE_ENV_FILE` 是普通文件;若位于 `REMOTE_DIR` 内,继续使用当前精确相对路径保护。任何验证失败均在 rsync 前退出。
- 已有生产部署的参数只从受控 automatic-backup systemd unit 的 allowlist 环境读取,并交叉核对活动 Compose project 与挂载;禁止从 `com.docker.compose.project.working_dir` 猜仓库根目录。首次部署继续使用人工审核的显式参数。
### 14.3 Docker 缓存与构建身份
- `base` 只安装稳定系统依赖;client/server dependency stages 只由各自 lockfile(以及 Prisma generate 所需 schema)决定。
- `VITE_BUILD_SHA` 只在 `client_build` 执行 Vite 前注入;`BUILD_SHA` 只在 `server_build`/最终运行层注入。改变 SHA 必须重建应用产物,但不得重跑基础包和 `npm ci`。
- 静态合同测试断言 Dockerfile 中 `apk add`、client `npm ci`、server `npm ci` 均早于对应 build arg;既有 deploy 来源、`/ready` 和公网短 SHA 检查继续作为运行时证明。
### 14.4 日志与回退
- seed/smoke 只输出 readiness、计数和布尔状态,不输出完整邮箱、电话、企业微信 corp/agent 标识、redirect URI 或一次性密码。需要首次凭据时必须通过受控配置渠道提供,不从部署日志交付。
- preflight 失败不需要回滚,因为尚未同步。镜像构建或 smoke 失败时沿用旧容器/既有发布回退合同,不通过重建 `.env` 作为常规路径。
- 回归至少覆盖错误仓库根、缺失远端 env、无效 existing 数据目录都不会调用 rsync;正确合同可进入 rsync;Docker 缓存顺序与日志禁敏片段均有静态断言。
@@ -16,6 +16,15 @@
- [x] 运行 R5 双设备、flow coverage/本地候选资产解析、前端 typecheck/build 与 Trellis 治理门禁;记录约 18 个其他孤儿 spec 为后续治理,不在本轮扩范围。
- [x] 修正上一轮证据中的错误表述:页面实际仍有四个入口,只有旧采集器因 key 漂移看见两个;本轮未推送、未部署前不宣称线上已修复。
## 0.3 生产部署流程加固(本轮部署后)
- [x] 如实记录 `5dac336` 发布的首次错误根目录尝试、`.env` 原子恢复、第二次成功发布、独立构建身份和备份链验证;真实业务数据与审批状态不变。
- [x] 把远端仓库根、`SYNC_ENV=0` env、existing PGDATA/storage 验证前移到 rsync 之前;错误合同必须在任何远端同步前停止。
- [x] 重排 Dockerfile,使基础包与两套 lockfile 依赖层不受 build SHA 变化影响,同时保留前端短 SHA和后端完整 SHA。
- [x] 收敛 deploy seed/external/runtime smoke 的账号、联系方式和外部配置标识输出,只保留 readiness、计数和状态。
- [x] 增加部署顺序、错误停止点、Docker 缓存顺序和日志最小化回归;更新 deployment spec 与 runbook。
- [x] 运行聚焦单测、环境/来源合同、shell 语法、敏感扫描、Trellis/process/docs/diff 门禁;单独提交本地加固,不在未获再次批准时部署。
## 1. 生产写入前门禁
- [x] 只读复核活动 server 构建标识、Compose 项目、4 个 systemd service 当前 `BUILD_SHA`、timer enabled/active、最近源端备份与 ARM 新鲜度;输出仅限状态和计数。
@@ -365,3 +374,8 @@
- 候选门禁修复:`run-local-candidate.sh list` 首轮暴露隔离夹具漏建普通 `bash child-script`,修正为按命令行同时发现 `bash` 与 `bash -n` 子脚本;focused 首轮随后揭示 Wave3 合同页在 Radix 菜单仍打开时等待背景“筛选”按钮至 300 秒超时,改为先完成页面操作、最后验证“更多/导出 CSV”。最终本地候选 50 项、10 个核心 witness 全通过,0 failed、0 skipped、0 unexpected,状态为 `FOCUSED_BROWSER_PASS`。
- 门禁治理复验:QA Node 测试 52/52 通过;flow coverage 将 acceptance spec 从 7 项增至 8 项,8/8 有效且 0 blocking gap;前端最终 `typecheck` 与生产 build 明确 `exit=0`,PWA 产物生成完成。
- UX 治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。稳定的 PC 单层来源返回、共享人民币展示及 acceptance spec 必须接入真实门禁的合同已写入 `frontend-react-vite-design` spec。本步骤未部署、未推送、未修改生产配置或真实业务数据。
- `5dac336` 生产发布事故与恢复:首次尝试把 Compose 配置目录误当仓库根目录,rsync 已把源码同步到错误嵌套位置,随后因重复拼接的 `.env` 路径不存在而停止;旧容器未重建且持续服务,但远端 `.env` 被错误同步删除。恢复只从仍运行的 server 容器按 `.env.example` 允许键提取,163/163 键可恢复、关键键缺失 0,原子写回后权限为 `0600`;未输出值或原始环境。第二次使用正确根目录发布成功并清理错误嵌套代码树,不能表述为一次成功。
- `5dac336fbf851a4196ee6607e5705ee840dce92f` 生产成功证据:63 个 migration 无待执行项;`/ready`、external deps、runtime smoke 和公网资源扫描通过;公网、活动容器与 4 个运维 service 构建标识匹配,4 个 timer enabled/active,automatic backup daily 与 watchdog 均为 `success/0`。本次未改真实报销、审批或付款数据。
- 部署加固实现:新增既有部署包装入口,只从 automatic-backup systemd unit 读取 6 个 allowlist 参数且不回显值;远端仓库根、普通非 symlink `.env`、existing PGDATA 与 storage 在 `mkdir`/rsync 前检查。Dockerfile 把 `apk add`、client/server `npm ci` 移到 build SHA 之前;seed/smoke 不再打印完整登录标识、联系方式、企业微信标识、redirect URI 或生成凭据。
- Docker 双 SHA 实测:第一次冷构建 `server_build` 通过,基础包约 124 秒、client `npm ci` 约 66 秒、server `npm ci` 约 85 秒;仅把前后端构建 SHA 改为另一组值后,三层均明确显示 `CACHED`,第二次约 78 秒,主要剩余前端 typecheck/Vite/PWA 构建与薄的镜像导出。第二个镜像内完整 `BUILD_SHA` 与前端 12 位标识均命中预期。
- 加固验证:部署安全聚焦测试 7/7;server 全量 172 个文件、1136 项通过,退出码 0;Prisma validate、3 个 shell 语法、4 个 JS 语法、环境合同、task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过。文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。
@@ -59,6 +59,8 @@
- 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。
- 旧 R5 真机反馈用例必须跟随当前移动首页信息架构:从常用动作宫格动态采集实际入口,但对经理测试角色固定断言“新建报销、上传票据、请假记录、采购”四项完整集合,并继续验证触控尺寸、单行标签、SVG 图标和无重叠;不得把动态采集写成“采到什么就接受什么”的重言测试。
- R5 不得继续作为无人执行的孤立 spec。将其登记到现有本地候选门禁的 acceptance spec 清单,使 `run-local-candidate.sh` 每次候选回归都执行;其余未被任何门禁引用的历史 E2E 仅记录为后续测试资产治理范围,本轮不批量接入、跳过或删除。
- 本轮生产发布暴露出既有流程仍依赖人工解释 Compose `working_dir`,且远端 `.env`/数据目录检查发生在 rsync 之后;标准发布必须在任何远端同步前只读确认目标是仓库根目录、`SYNC_ENV=0` 的远端 `.env` 存在、existing 数据目录有效,任一不满足即停止且不执行 rsync。既有部署参数以受控 systemd unit 为 canonical source,不再从 Compose 标签临时拼装路径。
- Docker 生产镜像的依赖层不得因每次变化的 `BUILD_SHA/VITE_BUILD_SHA` 失效。基础系统包与 client/server lockfile 依赖安装必须位于构建标识注入之前;构建标识仍须进入后端运行时和前端 12 位公开标识。部署流程与 smoke 日志不得输出完整登录标识、联系方式、企业微信配置标识或 Secret。
## Scope
@@ -112,6 +114,9 @@
- [x] PC/移动报销详情的总额、明细和发票金额统一使用共享人民币格式,覆盖整数、小数、千分位、空值和非法值。
- [x] 移动草稿详情“报销材料”区保留“添加发票”,底部固定栏不再重复该动作且“提交审批”仍可见、可达并满足安全区/触控尺寸合同。
- [x] R5 双设备用例按当前四项常用动作合同通过,且已进入本地候选门禁的 acceptance spec 清单;门禁资产解析能列出该 spec,不再依赖手工全目录执行才发现漂移。
- [x] 标准部署在 rsync 前验证仓库根目录、远端 `.env` 与 existing 数据目录;错误根目录或缺失 `.env` 的合成回归与脚本顺序断言证明失败发生在 rsync 之前。
- [x] Dockerfile 顺序合同及双 SHA 实际构建证明提交 SHA 变化不会使 `apk add`、client `npm ci` 或 server `npm ci` 层失效,同时前端短 SHA 与运行镜像完整 SHA 仍可核对;`/ready` 合同未改动。
- [x] 部署 runbook/spec 固化既有部署参数来源、只读 preflight、失败停止点和脱敏日志边界;本地验证通过后单独提交,不把该未部署的流程加固冒充为已上线。
## Out of Scope
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,89 @@
# 生产密钥恢复与环境文件外置:设计
## 1. 设计目标
本任务解决两个相互关联但必须分阶段交付的问题:
1. 主机或环境文件丢失时,人知道每个配置键如何恢复、代价是什么、由谁完成。
2. 生产环境文件不再位于源码同步树内,任何 `rsync --delete` 都无法物理触及它。
Phase 1 只建立恢复合同;Phase 2 才改变生产运行路径。数据库/附件备份继续保持现有边界,不吸收明文 Secret。
## 2. Phase 1:机器可校验的恢复分类
### 2.1 分类清单
新增一个位于 `deploy/trialrun/` 的 JSON 合同文件,按类别保存 `.env.example` 键名数组:
```json
{
"version": 1,
"categories": {
"template_rebuild": [],
"external_reissue": [],
"direct_rotate": [],
"downtime_reset": [],
"preserve_original": []
}
}
```
- 文件只允许大写环境键名和固定类别,不含值、默认值副本、长度、哈希、账号或外部平台地址。
- `validate-env-contract.js` 解析 `.env.example` 和分类文件,断言全集相等、无重复、无未知类别、无多余键。
- 普通非敏感配置也必须进入 `template_rebuild`,避免清单只覆盖看起来像 Secret 的少数键。
- `FIELD_ENCRYPTION_KEY` 固定归入 `preserve_original`;`POSTGRES_PASSWORD`/`DATABASE_URL` 的关系在恢复卡中说明,数据库角色口令本身归入 `downtime_reset`。
### 2.2 恢复卡
更新现有 `deploy/trialrun/runbook.md` 紧急恢复卡,不新建第二份 runbook:
- 每类给出丢失影响、责任角色、恢复来源、停止条件和恢复后验证。
- 数据库流程只描述受控“重置”:停止应用写入与 DB 容器、备份认证配置、用无公网暴露的隔离启动临时调整认证、修改角色口令、恢复原认证配置、重启并执行 migration status/ready/runtime smoke。任何一步失败都保持入口关闭。
- `preserve_original` 明确原值丢失会导致既有密文永久不可读;不得用新随机值冒充恢复成功。
- 共享密码库由系统管理员和公司负责人双人可取。仓库只记录角色与季度取用演练结果,不记录产品地址、账号或恢复码。
### 2.3 Phase 1 验证
- 单元测试覆盖完整分类、重复键、未知键、漏键、非法类别和分类文件出现疑似值。
- 环境合同继续检查 config/example/Compose;分类变化纳入同一个失败出口。
- 敏感扫描证明新增文件只有键名与类别。
- Phase 1 不通过 SSH、数据库、Compose 或 systemd 访问生产。
## 3. Phase 2:生产环境文件外置
### 3.1 运行合同
- `REMOTE_ENV_FILE` 成为唯一运行事实源,必须是同步树外的安全绝对路径、普通文件、root 所有、`0600`。
- Compose 的四个 `env_file` 从字面量 `./.env` 改为必须显式提供的 `${REMOTE_ENV_FILE}`;同一变量也继续传给 Compose `--env-file` 负责插值。
- `deploy.sh`、`deploy-existing.sh`、远端 preflight、自动备份/WARCH3 安装器、备份 runner、巡检和收口脚本统一接收该变量,不保留 `${REMOTE_DIR}/deploy/trialrun/.env` 的生产默认值。
- 本地开发可显式使用仓库外的测试 env;validator 的否定合同针对生产 Compose/部署默认与路径嵌套,不因为开发者工作区存在 gitignored 测试文件而误报。
### 3.2 负向防护
- `deploy.sh` 在 provenance、mkdir、rsync 之前拒绝 `REMOTE_ENV_FILE == REMOTE_DIR` 或任一方向嵌套。
- `preflight-remote.sh` 验证 env owner/mode/非 symlink,并确认路径不在 `REMOTE_DIR` 下。
- `validate-env-contract.js` 拒绝 Compose 再出现 `env_file: ./.env`、部署脚本重新默认到同步树或 runbook 把生产事实源写回仓库目录。
- `rsync` 的 `.env` exclude 可以作为纵深防御保留,但不再承担安全正确性。
### 3.3 受控迁移状态机
1. **Prepare**:只读冻结活动 Compose、env 路径、4 个 service、PGDATA/storage 和构建身份;检查共享密钥封存完成状态,不读取值。
2. **Copy**:在主机上以 root 原子复制当前 env 到外置临时文件,核对键集合、owner/mode/非 symlink 后原子改名;不在日志输出 diff 或值。
3. **Switch**:重装 4 个 units,使用新 env 完成 Compose config、新 init 配置预检并切换应用。
4. **Verify**:migration status、`/ready`、external/runtime smoke、普通登录、企微登录/入口、automatic backup daily/watchdog 和 timer 身份均通过。
5. **Retire**:在同一批准窗口内按精确旧路径删除旧副本;随后验证旧路径不存在、容器与 units 只引用新路径。
任何 Verify 失败都在旧副本尚存时切回旧合同并重新安装 units;禁止在验证完成前删除旧副本。Retire 后若需回滚,只能使用外置事实源,不能把 Secret 搬回同步树。
## 4. 密钥版本化的延期边界
当前 `v1:<iv>:<tag>:<ciphertext>` 没有 key id,单一 `FIELD_ENCRYPTION_KEY` 无法支持安全轮换。本任务只保证原值可恢复,不修改加密服务、数据库密文或敏感字段 API。
后续独立安全任务至少需要:新格式版本与 key id、keyring 配置合同、当前写入键、旧键只读、惰性与批量重加密、覆盖银行账号/身份证号/通知渠道密文的枚举与计数、轮换回滚和旧键删除门禁。
## 5. 风险与回滚
- 最大风险是两份 env 共存期间扩大 Secret 暴露面,因此窗口必须短、两份均 root-only,完成后有否定删除验证。
- Compose 插值与 service `env_file` 是两个用途,必须同时验证,不能只改命令行 `--env-file`。
- 数据库口令重置步骤属于事故恢复,不在常规部署或自动测试中执行;文档不得弱化停机和隔离要求。
- 具体密码管理器和成员授权是仓库外 EXTERNAL_GATED 动作,缺少双人取用确认时 Phase 2 不得开始。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,48 @@
# 生产密钥恢复与环境文件外置:实施计划
## 0. 边界
- [x] 本任务按 Phase 1、Phase 2 分开批准、提交和验收;Phase 1 批准不授权生产访问或迁移。
- [x] 不读取、打印、哈希或提交生产 Secret;只处理键名、类别、布尔状态和计数。
- [x] `FIELD_ENCRYPTION_KEY` 版本化/keyring/重加密登记为后续任务,不在本轮顺手实现。
## 1. Phase 1:恢复卡与分类合同
- [x] 从 `.env.example` 自动提取当前键集合,逐项归入 5 个固定类别;对 `FIELD_ENCRYPTION_KEY`、数据库口令、JWT/pepper、外部平台凭据做人工复核。
- [x] 新增无值的 JSON 分类合同,并扩展 `validate-env-contract.js`:全集相等、恰好一次、类别固定、键名格式正确、内容不含疑似赋值/Secret 正文。
- [x] 更新 `deploy/trialrun/runbook.md` 顶部恢复卡,写清 5 类影响、责任人与恢复后验证;数据库为隔离停机重置,不写成明文找回。
- [x] 更新 `.trellis/spec/hyb-oa/deployment-trialrun.md`,固化环境恢复分类、双人托管、`preserve_original` 最高风险和 keyring 延期边界。
- [x] 增加聚焦单元测试:正常 164-key 合同、漏键、重复键、多余键、未知类别、非法键名/疑似值、`FIELD_ENCRYPTION_KEY` 与 `POSTGRES_PASSWORD` 错误分类。
- [x] 运行环境合同、聚焦测试、server 全量测试、JS 语法、task/governance/process/docs/sensitive/diff 门禁。
- [x] Phase 1 单独提交;证据明确“未访问或修改生产”,不把文档完成冒充密钥已封存。
## 5. Phase 1 验证证据(2026-08-01)
- 环境合同:PASS,`.env.example` 164 个键,恢复分类 164 个键,恰好覆盖一次。
- Prisma 与语法:`prisma validate`、validator/test `node --check` 均 PASS。
- 服务端全量:173 个测试文件、1144 个测试 PASS;其中恢复分类聚焦用例 8/8 PASS,既有部署安全用例 7/7 PASS。
- 治理门禁:task validate、Trellis governance、process contract、sensitive-content scan、`git diff --check` 均 PASS;docs registry audit 为 0 error、1 个既有汇总 warning(22 个未登记 docs 文件)。
- 人工复核:五类语义、双人托管、PostgreSQL 隔离停机重置与 `FIELD_ENCRYPTION_KEY` 原值保留边界一致;清单仅含键名和类别。
- 操作边界:本阶段未连接、读取或修改生产;未创建密码库条目,未迁移 env,未修改 Compose/systemd,未重启服务,也未删除任何生产文件。
## 2. Phase 2:代码与脚本准备(仍不自动迁移)
- [ ] Compose 四个 service 使用显式 `${REMOTE_ENV_FILE}`;部署、备份、WARCH3、巡检和 QA 脚本移除同步树内生产默认。
- [ ] `deploy.sh`、preflight 和环境合同拒绝 env 与同步树重叠,校验 root owner、`0600`、普通非 symlink 文件;保留 rsync exclude 仅作纵深防御。
- [ ] 更新 runbook、checklist、管理员手册和 OPS7 中所有生产路径示例;本地示例明确使用非生产测试 env。
- [ ] 扩展合成回归,覆盖旧路径拒绝、新路径通过、Compose v1/v2 config、4 个 unit 参数一致、rsync 前失败和旧副本否定检查。
- [ ] 运行 Docker Compose config、部署合同、备份合同、全量测试与 Trellis 门禁;单独提交但不连接生产。
## 3. Phase 2:生产迁移(另行明确批准)
- [ ] 只读冻结活动 project、构建、当前 env、PGDATA/storage、4 个 service/timer;确认系统管理员与公司负责人均可取用 `preserve_original` 封存项,只记录完成状态。
- [ ] root 原子复制到同步树外目标,校验键集合、owner/mode/非 symlink;不输出值或文件内容。
- [ ] 重装 4 个 units,运行 Compose config、新 init 配置预检并切换 server;未获批准不启用 worker/minio profile。
- [ ] 验证 migration status、`/ready`、external/runtime smoke、普通登录、企微入口、automatic backup daily/watchdog、4 个 timer 和构建身份。
- [ ] 验收全部通过后按精确旧路径删除旧副本;再次证明旧路径不存在,活动容器和 units 只引用新路径。
- [ ] 任一验证失败时,在旧副本删除前回滚 unit/Compose 引用;记录失败尝试,不美化为一次成功。
## 4. 后续独立任务
- [ ] 建立 `FIELD_ENCRYPTION_KEY` 版本化与安全轮换任务:带 key id 的新密文格式、keyring、惰性/批量重加密、全字段覆盖、审计与旧键删除门禁。
- [ ] 约 18 个孤儿 E2E 继续作为独立测试治理任务,不与密钥恢复混做。
@@ -0,0 +1,77 @@
# 生产密钥恢复与环境文件外置
## Goal
在不把生产 Secret 写入仓库、普通备份、日志或聊天的前提下,为 OA 建立可执行的环境配置恢复合同,并把生产环境文件迁出源码同步树,避免 rsync 误删、单机损坏或单管理员缺席导致系统无法恢复。
## Background
- `5dac336` 首次发布误把 Compose 配置目录当作仓库根目录,`rsync --delete` 删除了远端 `.env`;本次仅因旧 server 容器仍在运行,才能按允许键恢复配置。
- 当前数据库和附件已有异机备份,环境文件及密钥没有独立恢复合同。主机整体丢失时,仅恢复数据库与附件不足以启动 OA。
- `07de198` 已把仓库根、远端 env、PGDATA 和 storage 的检查前移到 rsync 之前,封住最可能复发路径,但没有解决整机丢失、密钥托管或环境文件仍位于同步树内的问题。
- 当前 `.env.example` 合同含 164 个键;生产事故恢复曾观察到 163 个可恢复键。第一阶段只按键名和用途分类,不读取、输出或提交生产值;第二阶段再只读解释生产键集合差异。
- 仓库中不只有 4 个 systemd service 引用 `REMOTE_ENV_FILE`:Compose、部署/备份入口、自动备份安装器、日常巡检脚本、若干 QA 收口脚本及 runbook/checklist 都存在当前路径假设,迁移必须统一收敛。
- `FIELD_ENCRYPTION_KEY` 已用于银行账号、身份证号和通知渠道 Secret 等数据库密文。原值丢失后不能从数据库反推;直接换新值会使既有密文不可读,除非持有旧值完成受控重加密。
## Scope
- 当前获批并执行的范围仅为 Phase 1:脱敏恢复分类、现有恢复卡、环境合同校验、聚焦测试与稳定规约。
- Phase 2 的代码合同、生产 env 外置及旧副本删除仍保留在本任务中规划,但必须分别取得后续明确批准后才可实施。
- 本轮不访问生产,不读取或托管 Secret,不修改 Compose/systemd,不重启服务,也不执行任何生产删除。
## Requirements
### Phase 1:恢复合同与分类(不碰生产)
- 在现有 `deploy/trialrun/runbook.md` 的紧急恢复卡中增加环境配置恢复,不另建重复操作手册。
- 用机器可校验的脱敏清单覆盖 `.env.example` 中每一个键;清单只包含键名和恢复类别,不含值、长度、哈希或原始 payload;各类别的影响和责任边界统一写入现有恢复卡。
- 至少支持用户已确认的四类:
1. `template_rebuild`:端口、路径、开关、TTL 等,从 `.env.example` 与 runbook 重建。
2. `external_reissue`:企微、Webhook、腾讯云、SMTP、OIDC、S3/AI/ERP 等外部凭据,从对应平台重新签发或获取。
3. `direct_rotate`:JWT、验证码 pepper、试运行管理员口令等可直接轮换项;必须写明会话失效、验证码失效或账号重置等代价。
4. `downtime_reset`:数据库口令等不能反推明文、但可在保留 PGDATA 时通过隔离停机操作重置的项目。恢复卡必须明确这是“重置”而非“找回”,先备份认证配置、停止应用写入、隔离数据库、临时调整认证、修改角色口令、恢复认证配置并做完整 smoke;不得让数据库临时暴露公网。
- 第 5 类 `preserve_original` 为最高风险:`FIELD_ENCRYPTION_KEY` 等必须保存原值的密钥,只能从受控共享密码库/加密封存副本恢复;没有旧值时不得宣称轮换即可恢复,必须如实说明既有银行账号、身份证号和通知渠道密文将不可读并需重新收集/配置。
- 密钥封存不得只由单一管理员持有。共享密码库或加密封存副本至少由系统管理员和公司负责人各自可取;仓库只记录责任角色、复核周期和恢复验证,不记录产品库地址、账号、恢复码或密钥正文。
- 第一阶段限制为文档、脱敏分类清单、静态校验和测试,目标半天内完成;不连接生产、不创建密码库条目、不迁移 env、不重启服务。
### Phase 2:环境文件外置(另行生产批准)
- 生产 `REMOTE_ENV_FILE` 迁移到源码同步树外的 root-only 绝对路径,目标权限为 root 所有、`0600`、普通非 symlink 文件。
- Compose 的 `server`、`worker`、`init`、`minio_init`,部署入口、备份/巡检脚本和 4 个运维 service 必须统一使用显式 `REMOTE_ENV_FILE`,不再依赖 `./.env`。
- `deploy.sh` 对 existing 生产部署拒绝 `REMOTE_ENV_FILE` 与 `REMOTE_DIR` 相同或互相嵌套;`validate-env-contract.js` 负向断言生产 Compose/部署合同不得把 env 放回同步树。不能仅依赖 rsync exclude。
- 迁移使用受控短暂停机/切换窗口:从当前事实源原子复制到外置路径,验证 owner/mode/非 symlink 与键集合,更新全部 units,完成 Compose config、新 init 配置预检、应用切换、`/ready`、外部依赖、runtime smoke、登录/企微和备份 timer 验证。
- 迁移期允许存在新旧两份密钥副本作为明确回退点;验收后必须按精确旧路径删除旧副本,并再次验证旧文件不存在、运行容器与 systemd 均引用新路径。删除属于生产破坏性动作,必须在第二阶段批准窗口内显式执行和记录。
- 共享密码库具体产品、成员授权和生产 Secret 写入由用户/公司负责人在仓库外完成;Codex 只验证职责与完成状态,不读取 Secret。
## Acceptance Criteria
- [x] `.env.example` 当前全部键恰好归入一个获批恢复类别;新增、删除或重复分类会使合同测试失败。
- [x] 恢复卡能分别回答模板重建、外部重申请、直接轮换、数据库停机重置和原值保留;每类写明业务影响、操作边界和恢复后验证,`preserve_original` 标为最高风险。
- [x] 数据库口令恢复明确使用隔离停机“重置”流程,不声称可从 `pg_authid` 反推明文,不留下 trust 认证或公网暴露。
- [x] 密钥托管职责至少覆盖系统管理员与公司负责人两人;仓库、日志和普通数据库/附件备份中不出现密钥正文。
- [x] Phase 1 的文档、分类清单、validator 和单元测试通过,且没有生产访问或写入。
- [ ] Phase 2 代码合同使生产 env 物理位于同步树外,并负向拒绝同步树内 env;所有 Compose/部署/备份/巡检引用统一。
- [ ] 获批生产迁移后,新路径 owner/mode、Compose config、init 配置预检、`/ready`、外部依赖、runtime smoke、登录/企微及 4 个 timer 均通过。
- [ ] 旧副本删除后有否定验证,生产运行合同仍指向新路径;可回退点、删除时间和操作人有脱敏审计记录。
## Out of Scope
- 不把明文 `.env` 加入现有数据库/附件备份或 Git。
- 不在本任务内采购或替用户选择密码管理器产品。
- 不让 Codex、CI、聊天或任务证据读取/保存生产 Secret。
- 不借机轮换所有密钥、重加密全部敏感字段或重构配置中心。
- 不在本任务实现密钥版本、keyring、惰性重加密或全量重加密;另立安全设计任务后才能改变密文格式。
- 不在 Phase 1 迁移生产路径、更新 systemd、重启容器或删除旧 env。
## Known Limitation / Deferred
- 当前 AES-256-GCM 密文格式为 `v1:<iv>:<tag>:<ciphertext>`,只有格式版本,没有 key id;服务端配置也只有单一 `FIELD_ENCRYPTION_KEY`,因此无法判断密文由哪把钥匙生成或安全并存新旧钥匙。
- `preserve_original` 是当前恢复现实,不是永久设计。若将来怀疑密钥泄露,现有系统无法在保持既有密文可读的同时轮换。
- 后续独立任务应设计带 key id 的新密文版本、受控 keyring、写入只用当前钥匙、旧密文读取后的惰性重加密,以及覆盖全部密文字段的可审计批量迁移/回滚;在该设计完成前不得删除旧钥匙。
## Test Plan
- 环境与语法:`node deploy/trialrun/validate-env-contract.js`、相关 `node --check`、`cd server && npm run prisma:validate`。
- 回归测试:聚焦运行恢复分类与部署安全单元测试,并运行 `cd server && npm test` 全量测试。
- Trellis/文档门禁:task validate、Trellis governance、process contract、docs registry audit、sensitive-content scan 与 `git diff --check`。
- 人工复核:逐项检查 164 个键的恢复语义、恢复卡的停机/隔离边界,以及新增材料不含生产值、哈希或真实托管位置。
@@ -0,0 +1,26 @@
{
"id": "production-secret-recovery-env-externalization",
"name": "production-secret-recovery-env-externalization",
"title": "生产密钥恢复与环境文件外置",
"description": "分阶段建立生产环境配置恢复合同,并在另行批准后将生产 env 迁出源码同步树。",
"status": "in_progress",
"dev_type": null,
"scope": "Phase 1: 脱敏恢复分类与恢复卡;Phase 2: 生产 env 外置代码合同及另行批准的迁移",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-01",
"completedAt": null,
"branch": "main",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,56 @@
# 报销通知与处理进度简化:设计
## 1. 设计目标
用最少改动恢复真实待办通知,并让员工看到职责清晰的报销全流程。工作流审批、付款批次和付款执行仍是三个独立状态源;页面只做汇总,不把它们合成一个状态机。
## 2. 通知只读诊断
按以下顺序诊断目标财务任务,任何一步都只记录布尔状态、计数、固定结果码和脱敏结论:
1. 从目标报销的最新工作流实例定位主管已完成任务与当前财务任务,不保存内部 ID 到仓库证据。
2. 以当前财务任务为 `target_id` 查询 `workflow.task_assigned` 站内通知,并确认收件人是否为预期财务人员。
3. 若通知不存在,回到主管审批后的下一任务创建/收件人解析路径;若存在,继续查询 delivery 状态与固定 `result_code`。
4. 只核对收件员工 active、`notification_push_enabled`、企微绑定是否存在,及启用渠道能否匹配 workflow 域和财务角色;不读取 userid、渠道 Secret、消息正文或响应原文。
5. 只读结论不足以授权配置修改、补发、重启或生产写入,后续动作单独申请。
## 3. 审批动作标签
- `client/src/lib/approvalDisplay.ts` 的 `workflowActionLabel()` 为唯一共享映射。
- PC `WorkflowTaskDetailPage.tsx` 和移动 `MobileApprovalsPage.tsx` 删除局部副本并导入共享函数。
- 保持未知动作的原值兜底用于诊断,但所有当前受支持动作必须有中文映射;测试覆盖 `start` 与 `submit`。
## 4. 员工进度表达
- 后端处理进度阶段键保持兼容,只把普通详情的标签调整为“付款复核”“付款完成”。
- `付款完成` 当前节点/待处理说明明确“由财务登记,非审批环节”;已完成时继续显示付款人、时间及回单是否留存。
- “查看所在付款批次”只对已有批次读取权限的人显示,普通员工不接触内部批次对象名。
- 五阶段继续使用一条竖向进度线,因为它表达全流程进展;文案区分审批与付款,不另加 Tab、折叠或第二张流程图。
## 5. 付款阶段通知
本节实施以第 2 节诊断结论为前置门:若原链路是投递配置问题,付款阶段只新增复用既有 dispatcher 的发送点;若原链路是通知创建或 side-effect flush 的代码问题,先修复并用既有主管→财务通知证明公共路径,再让两个付款发送点复用同一修复。禁止在根因未定时并行新增后再混合排障。
### 5.1 批次创建后
- 新建批次事务内创建“待付款复核”站内通知,事务提交后走现有 IM dispatcher。
- 收件人由 active 员工的真实角色权限计算:必须有 `payment.batch.review` 且角色包含 `manager` 或 `ceo`,并排除批次创建人;不得写死姓名或向所有管理角色泛发。
- 通知目标是 `payment_batch`,deeplink 进入现有付款页面的目标批次。
### 5.2 批次复核后
- 复核事务内创建“待执行付款”通知,收件人优先为仍 active 且具备 `payment.batch.manage` 的批次创建人;创建人不可用时,才解析其他具备管理权限的 active 财务人员,并排除当前复核人。
- 不把付款动作通知给普通申请人;申请人在批次执行成功后继续收到既有 `reimbursement.paid`。
### 5.3 合同与幂等
- 新增明确的付款待办通知类型,不复用“报销已付款”或“工作流待审批”伪装语义。当前付款批次只开放报销实体,因此通知映射到既有 `reimbursement` domain,复用现有渠道配置;未来若重新开放其他实体付款,再按实体拆分 domain,不在本轮新建需要生产改配的 `payment` domain。
- 通知创建与业务状态变更在同一事务内;投递失败不回滚已完成业务动作,但保留 delivery 失败证据。
- 重复创建/复核请求由既有状态锁拒绝,测试证明不会产生重复通知。
## 6. 兼容与回滚
- 不修改数据库 schema、历史审批快照、既有通知记录或付款批次状态。
- 文案改动可直接回滚;共享映射回滚不会影响服务端事实。
- 付款通知若出现收件范围错误,关闭对应通知类型/代码路径即可,不撤销已经创建或复核的付款批次。
- 本轮不合并财务复核与组批;累计 5–10 张真实单后的用户访谈是重新立项条件,不是当前发布门禁。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,49 @@
# 报销通知与处理进度简化:实施计划
## 0. 边界与前置
- [x] 获得最终规划批准后再启动任务;生产只读诊断随本计划获批,任何生产写入、补发、配置修改或重启仍需另行批准。
- [x] 不修改审批链、付款 maker-checker、数据库 schema、历史任务或真实付款数据。
- [x] 生产诊断不输出通知正文、完整账号、企微 userid、内部业务 ID、Secret 或原始响应。
## 1. 生产只读通知诊断
- [x] 定位目标报销最新工作流的当前财务任务,先查 `workflow.task_assigned` 站内通知是否存在。
- [x] 有通知则查 delivery 状态/固定结果码、推送开关、绑定存在性和渠道匹配;无通知则核对下一任务与收件人解析事实。
- [x] 形成只含布尔值、计数和错误分类的结论;需要修生产配置、补发或重启时停止并申请批准。
诊断结论(脱敏):当前财务任务及对应站内通知均已生成,收件人 active、开启推送且存在企微绑定;投递尝试为 0,生产无启用且匹配的企微通知渠道。根因位于投递配置层,不是审批推进或通知创建代码。本任务没有修改生产配置、补发、重启或部署。
## 2. 共享动作标签
- [x] PC 与移动审批页移除局部 `workflowActionLabel`,统一导入共享实现。
- [x] 增加 `start`/`submit` 中文映射回归,检查打印及其他调用方没有第三份重复映射。
## 3. 处理进度文案
- [x] 将员工详情的后两阶段改为“付款复核”“付款完成”。
- [x] 付款完成待处理说明明确“由财务登记,非审批环节”,保留权限门控的批次入口和回单完成状态。
- [x] 更新 PC/移动组件测试,断言五阶段顺序与普通员工不暴露内部批次入口。
## 4. 付款责任人通知
- [x] 以前述只读诊断结论为实施门:先证明公共通知创建/投递路径的根因与修复边界,再确定本节只新增发送点还是必须复用同一代码修复。
- [x] 增加付款复核待办与付款执行待办的通知类型、domain 和脱敏文案。
- [x] 批次创建事务内解析有复核权限的 active manager/ceo,排除创建人并创建通知;提交后复用现有 dispatcher。
- [x] 批次复核事务内通知 active 批次创建人/实际付款责任人,排除复核人;付款执行成功沿用现有申请人通知。
- [x] 覆盖无合格收件人、创建人停用、重复请求、投递失败不回滚业务和普通用户不可读批次等边界。
## 5. 验证与审查
- [x] 运行 workflow/reimbursement/payment-batch 聚焦 Vitest、付款并发与 maker-checker 回归。
- [x] 运行客户端 typecheck/build、相关 PC/移动 Playwright 和动作标签单测。
- [x] 运行 Prisma validate、task/governance/process/sensitive/diff 门禁。
- [x] 因涉及 workflow/付款职责和通知收件范围,实施后执行只读 reviewer 检查,不允许 reviewer 改代码。
- [ ] 部署与公网检查另行批准;未部署前不通知用户可以测试,企微实际到达必须真机确认。
只读审查结论:首次检查发现站内通知中心缺少 `payment_batch` 目标链接,已补 PC/移动统一直达付款页并增加回归;复查未发现阻断项。通知记录与付款动作同事务,IM 投递发生在提交后且失败不回滚;复核人解析排除制单人,付款责任人解析排除复核人;重复创建/复核由既有锁和状态校验拒绝,不重复通知。生产仍无启用且匹配的企微渠道,属于已知部署后配置门,不得表述为企微通知已恢复。
## 6. 运行观察
- [x] 运行约定为每周集中建立一次付款批次,不建设排程功能。
- [ ] 王芝玲累计处理 5–10 张真实报销后触发一次访谈,决定财务复核与加入批次是否值得另立合并任务(未来触发条件,当前尚未发生)。
@@ -0,0 +1,61 @@
# 报销通知与处理进度简化
## Goal
让报销单在 5–10 人公司的真实流程中做到两件事:新的责任人能及时收到待办通知;申请人能看懂“审批”和“付款处理”的区别,不再把四个动作误认为四轮审批。
## Background
- 马建勇已在生产完成杨俊报销的主管审批,但下一节点王芝玲没有收到预期审批通知;王芝玲此前已通过企业微信登录,不能把问题归因于“从未登录”。
- 代码在报销提交和工作流流转时会创建 `workflow.task_assigned`,主管通过后也会生成下一任务并排队通知,因此应先区分“站内通知未生成”和“已生成但企微未投递”。
- 当前员工详情展示“付款批次复核”“付款与回单”,容易被理解为两轮额外审批;实际前者是非制单人付款复核,后者是财务线下付款后的回单与状态登记,不是审批。
- 当前职责基线:马建勇负责主管审批和非制单付款复核;王芝玲负责财务复核、创建付款批次、线下付款、上传回单和登记付款完成。一个付款批次可以覆盖多张报销单。
- `client/src/lib/approvalDisplay.ts` 已把 `start` 映射为“提交”,但 PC `WorkflowTaskDetailPage.tsx` 与移动 `MobileApprovalsPage.tsx` 各自复制了缺少 `start` 的局部映射,导致显示 `start · 杨俊`。
## Scope
- 先做一次获批的生产只读诊断:只核对目标工作流任务、王芝玲对应站内通知是否存在、投递记录状态/固定错误分类、通知开关、企微绑定状态和渠道匹配状态;不读取 Secret、消息正文或原始企微 payload。
- 删除 PC/移动审批页重复的动作标签映射,统一使用共享 `workflowActionLabel()`;`start` 和 `submit` 均显示“提交”。
- 员工报销进度保留五个业务阶段,但改为:申请提交、主管审批、财务复核、付款复核、付款完成;付款完成明确为“财务登记,非审批环节”。
- 保留付款 maker-checker:付款批次创建人与复核人不得相同,当前只需一次非制单复核,不增加更多复核层级。
- 通知以“新的责任人需要行动”为准:提交后通知主管、主管通过后通知财务、创建付款批次后通知非制单复核人、付款复核后通知财务/批次创建人继续付款、付款完成后通知申请人。
- 每周集中建立一次付款批次作为当前运行约定,不在本任务建设定时排程、自动组批或强制付款日。
## Requirements
- 通知诊断必须先判断站内通知是否生成,再进入企微投递层,不能直接假设缺少通知代码或重复创建通知。
- 若本次缺失由生产配置、人员推送开关、企微绑定或渠道配置造成,先给出脱敏根因和最小修复建议;任何生产写入、配置修改、补发消息或重启均需再次批准。
- 新增付款阶段通知时必须复用现有通知、投递和审计服务;通知只给真实需要行动的人,不向所有 finance/manager 角色泛发重复消息。
- 普通员工页面不突出“付款批次”内部对象名;有付款批次权限的人员仍可通过现有权限门控入口查看批次。
- 共享动作标签是唯一事实源;PC、移动和打印页不得继续维护互相漂移的局部映射。
- 不删除主管审批、财务复核或付款复核,不放宽自审,不把付款完成伪装成审批通过。
## Acceptance Criteria
- [ ] 脱敏证据能明确回答本次财务通知属于“未生成”还是“已生成未送达”,并定位到单一失败层或明确下一步,不包含通知正文、账号、Secret 或原始 payload。
- [ ] 马建勇通过主管审批后,王芝玲能够收到对应的新待办站内通知;企微投递恢复需另有真实投递证据后才能标为通过。
- [ ] PC 与移动审批处理历史均显示“提交 · 杨俊”,不再出现原始 `start`;动作标签映射只保留一个共享实现。
- [ ] 普通报销详情依次显示“申请提交、主管审批、财务复核、付款复核、付款完成”,并明确付款完成不是审批。
- [ ] 创建付款批次后通知实际可复核人员;复核通过后通知实际付款责任人;付款完成继续通知申请人,且同一事件不会重复创建/投递。
- [ ] 付款复核仍强制非制单人且仅一轮;既有付款批次、审批快照和历史记录不迁移、不改写。
- [ ] PC/移动相关组件测试、通知路由/服务测试、付款 maker-checker 回归、前端 typecheck/build 与 Trellis 门禁通过。
## Out of Scope
- 不在本任务删除付款批次、付款复核或银行回单要求。
- 不接银行接口,不让 OA 自动执行真实付款。
- 不建设每周付款日排程、自动组批、批次自动复核或定时通知。
- 不在未获批准时修改生产通知渠道、员工推送开关、企微绑定、环境变量或服务状态。
- 不顺带重构全部审批页面或统一所有业务状态标签。
## Deferred Decision With Trigger
- 本轮不合并“财务复核通过”和“加入/创建本周付款批次”。决定性依据是王芝玲尚未实际执行过财务复核,无法判断她会随到随审还是与周付款批次一起处理;提前合并可能迫使她延迟复核或频繁建批次。
- 触发条件:王芝玲累计处理 5–10 张真实报销单后,由负责人直接询问“财务复核与加入付款批次通常一次完成,还是分开完成?”若一次完成,再立小任务评估合并;若随到随审、定期组批,则永久保持两个动作分离。
## Test Plan
- 聚焦测试动作标签、下一审批任务通知、付款批次创建/复核/执行通知和幂等性。
- 运行相关 server Vitest、付款并发/maker-checker 回归、客户端 typecheck/build 与相关 Playwright。
- 运行 task validate、Trellis governance、process contract、sensitive-content scan 与 `git diff --check`。
- 生产只读诊断和任何后续真机通知验证分开记录;没有实际企微到达证据时不得宣称通知已恢复。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-notification-progress-clarity",
"name": "reimbursement-notification-progress-clarity",
"title": "报销通知与处理进度简化",
"description": "诊断报销审批通知投递,统一审批动作中文映射,并按小团队实际职责简化付款阶段展示。",
"status": "in_progress",
"dev_type": null,
"scope": "报销审批通知只读诊断、审批动作标签去重、付款阶段文案与必要通知;不削弱付款 maker-checker",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-01",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+8
查看文件
@@ -18,6 +18,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
return `/reimbursements/${item.targetId}`;
}
if (item.targetType === "payment_batch") {
return `/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") {
return `/leave-requests?highlight=${item.targetId}`;
}
@@ -60,6 +64,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
return `/mobile/reimbursements/${encodeSegment(item.targetId)}`;
}
if (item.targetType === "payment_batch") {
return `/payment-batches?id=${encodeSegment(item.targetId)}`;
}
if (item.targetType === "leave_request") {
return `/mobile/leave/${encodeSegment(item.targetId)}`;
}
+1 -22
查看文件
@@ -31,7 +31,7 @@ import { Spinner } from "../components/ui/spinner";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
import { MOBILE_APPROVAL_PERMISSIONS } from "../features/mobile/navigation";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { businessPrimaryLabel, compactBusinessReference, joinBusinessMeta } from "../lib/businessDisplay";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { cn } from "../lib/utils";
@@ -150,27 +150,6 @@ function workflowStatusLabel(status: string) {
return status;
}
function workflowActionLabel(action: string | null | undefined) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function businessStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
draft: "草稿",
+2 -1
查看文件
@@ -17,7 +17,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
{ label: "差旅", value: "travel_request.approved,travel_request.rejected" },
@@ -39,6 +39,7 @@ function formatTime(value: string) {
function notificationTypeLabel(type: string) {
if (type.startsWith("workflow.")) return "审批事件";
if (type.startsWith("reimbursement.")) return "报销事件";
if (type.startsWith("payment_batch.")) return "付款事件";
if (type.startsWith("leave.")) return "请假事件";
if (type.startsWith("purchase_request.")) return "采购事件";
if (type.startsWith("travel_request.")) return "差旅事件";
+1 -1
查看文件
@@ -17,7 +17,7 @@ const PAGE_SIZE = 20;
const NOTIFICATION_TYPE_OPTIONS = [
{ label: "全部", value: "" },
{ label: "审批提醒", value: "workflow.task_assigned" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" },
{ label: "请假", value: "leave.approved,leave.rejected" },
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
{ label: "差旅", value: "travel_request.approved,travel_request.rejected" },
+1 -22
查看文件
@@ -32,7 +32,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import {
buildPcDetailSource,
@@ -85,27 +85,6 @@ function workflowInstanceStatusLabel(status: string | undefined | null) {
return labels[status] ?? status;
}
function workflowActionLabel(action: string | undefined | null) {
if (!action) {
return "-";
}
const labels: Record<string, string> = {
submit: "提交",
approve: "通过",
reject: "驳回",
return: "退回",
transfer: "转审",
withdraw: "撤回",
remind: "催办",
overdue_remind: "超时提醒",
cancel: "取消",
skip: "跳过",
};
return labels[action] ?? action;
}
function DetailField({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="detail-fact-card">
@@ -1,6 +1,10 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test, type Browser, type Page, type Route } from "@playwright/test";
import {
resolveMobileNotificationTargetLink,
resolveNotificationTargetLink,
} from "../../src/lib/notificationLinks";
const ROOT_DIR = path.resolve(process.cwd(), "..");
const SCREENSHOT_DIR = path.join(ROOT_DIR, "output/playwright/mobile-ia-flow-remediation");
@@ -881,6 +885,13 @@ async function expectCommonActionsDoNotDuplicateBottomTabs(page: Page) {
}
test.describe("MIF7 移动信息架构整改", () => {
test("付款责任通知在 PC 与移动通知中心都可直达付款批次", () => {
const target = { targetType: "payment_batch", targetId: "batch/dg7" };
expect(resolveNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7");
expect(resolveMobileNotificationTargetLink(target)).toBe("/payment-batches?id=batch%2Fdg7");
});
test.skip(process.env.E2E_MODE === "live", "MIF7 本地专项使用 mock 数据验证信息架构。");
test("首页常用入口不重复底部 Tab,员工进度页按状态分标签", async ({ browser }) => {
@@ -1108,7 +1119,7 @@ test.describe("MIF7 移动信息架构整改", () => {
[
"mobile-reimbursement-items",
"mobile-reimbursement-attachments",
"mobile-reimbursement-approval-history",
"mobile-reimbursement-processing-progress",
].map((testId) => document.querySelector(`[data-testid='${testId}']`)?.getBoundingClientRect().top ?? -1),
);
expect(sectionOrder[0]).toBeLessThan(sectionOrder[1]);
@@ -155,8 +155,8 @@ const reimbursementDetail = {
{ key: "manager_approval", label: "主管审批", state: "current", actorName: visualMe.name, occurredAt: "2026-05-27T09:00:00.000Z", result: "处理中", comment: null, detail: "待主管审批处理", targetHref: null },
{ key: "approval_extra-review", label: "负责人复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
{ key: "payment_batch_review", label: "付款复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务安排付款", targetHref: null },
{ key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null },
],
},
approvalHistory: [
@@ -1101,11 +1101,12 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("负责人复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款批次复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).toContainText("付款复核");
await expect(page.getByTestId("reimbursement-workflow-progress")).not.toContainText(/第 \d+ 步/);
await page.goto("/workflow/tasks/task-wave3");
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.getByText(`提交 · ${visualMe.name}`)).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile/reimbursements/reim-wave3");
@@ -1119,7 +1120,7 @@ test("报销详情固定使用上海时区并收敛只读信息", async ({ page
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看发票原件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /查看补充附件/ })).toHaveCount(1);
await expect(page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: /移除|删除/ })).toHaveCount(0);
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款与回单");
await expect(page.getByTestId("mobile-reimbursement-processing-progress")).toContainText("付款完成");
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
@@ -203,8 +203,8 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
{ key: "submitted", label: "申请提交", state: "current", actorName: "测试员工", occurredAt: null, result: "待提交", comment: null, detail: "待申请人提交", targetHref: null },
{ key: "manager_approval", label: "主管审批", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "finance_review", label: "财务复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待处理", targetHref: null },
{ key: "payment_batch_review", label: "付款批次复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务创建付款批次", targetHref: null },
{ key: "payment_and_receipt", label: "付款与回单", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款批次复核后处理", targetHref: null },
{ key: "payment_batch_review", label: "付款复核", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待财务安排付款", targetHref: null },
{ key: "payment_and_receipt", label: "付款完成", state: "pending", actorName: null, occurredAt: null, result: null, comment: null, detail: "待付款复核后由财务登记,非审批环节", targetHref: null },
],
},
approvalHistory: [],
@@ -71,8 +71,9 @@ test("E2E 报销处理进度:提交后按真实 workflow 展示职责进度",
await expect(reimbursementDetail.workflowProgress).toContainText("申请提交");
await expect(reimbursementDetail.workflowProgress).toContainText("审批");
await expect(reimbursementDetail.workflowProgress).not.toContainText("审批节点1");
await expect(reimbursementDetail.workflowProgress).toContainText("付款批次复核");
await expect(reimbursementDetail.workflowProgress).toContainText("付款与回单");
await expect(reimbursementDetail.workflowProgress).toContainText("付款复核");
await expect(reimbursementDetail.workflowProgress).toContainText("付款完成");
await expect(reimbursementDetail.workflowProgress).toContainText("非审批环节");
await expect(reimbursementDetail.workflowProgress).not.toContainText(/第 \d+ 步/);
await logout(page, EMPLOYEE.name);
+4 -5
查看文件
@@ -1,10 +1,5 @@
FROM node:22-alpine AS base
ARG BUILD_SHA=unknown
ARG VITE_BUILD_SHA=unknown
ENV BUILD_SHA=$BUILD_SHA
ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app
RUN apk add --no-cache bash curl
@@ -14,6 +9,8 @@ COPY client/package.json client/package-lock.json ./client/
RUN cd client && npm ci
FROM client_deps AS client_build
ARG VITE_BUILD_SHA=unknown
ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
COPY client ./client
RUN cd client && npm run build
@@ -23,6 +20,8 @@ COPY server/prisma ./server/prisma
RUN cd server && npm ci
FROM server_deps AS server_build
ARG BUILD_SHA=unknown
ENV BUILD_SHA=$BUILD_SHA
COPY server ./server
COPY scripts ./scripts
COPY --from=client_build /app/client/dist ./client/dist
+4 -1
查看文件
@@ -71,7 +71,10 @@
- [ ] 本地工作树 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
- [ ] 已有部署通过 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh` 执行;首次初始化才使用完整显式参数的 `deploy.sh`
- [ ] 未从 Compose `working_dir` 猜仓库根目录;同步前 preflight 已确认仓库根标识、普通远端 `.env`、PG_VERSION 与附件目录,且该检查发生在 rsync 之前
- [ ] 脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 构建日志显示 lockfile 未变化时 client/server 依赖层使用缓存;build SHA 变化没有触发 `apk add` 或两次 `npm ci`
- [ ] 发布命令显式携带批准的新 project、两个绝对数据路径和 `DEPLOY_DATA_MODE=existing`;未依赖 `.env.example` 相对默认值
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
- [ ] 探活:
+48
查看文件
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}"
fail() {
echo "[deploy-existing][ERROR] $1" >&2
exit 1
}
[[ "$DEPLOY_SERVER" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]] \
|| fail "必须显式提供安全的 DEPLOY_SERVER"
[[ "$DEPLOY_CONTRACT_UNIT" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] \
|| fail "DEPLOY_CONTRACT_UNIT 格式无效"
command -v ssh >/dev/null 2>&1 || fail "缺少 ssh"
unit_environment="$(ssh "$DEPLOY_SERVER" \
"systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value")"
[[ -n "$unit_environment" ]] || fail "无法读取既有部署合同 unit"
declare -A contract=()
for entry in $unit_environment; do
key="${entry%%=*}"
value="${entry#*=}"
case "$key" in
REMOTE_DIR|REMOTE_ENV_FILE|COMPOSE_FILE|COMPOSE_PROJECT_NAME|POSTGRES_DATA_DIR|STORAGE_HOST_DIR)
contract["$key"]="$value"
;;
esac
done
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME POSTGRES_DATA_DIR STORAGE_HOST_DIR; do
[[ -n "${contract[$key]:-}" ]] || fail "既有部署合同缺少 $key"
done
echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显"
DEPLOY_SERVER="$DEPLOY_SERVER" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
COMPOSE_FILE="${contract[COMPOSE_FILE]}" \
COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \
STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \
DEPLOY_DATA_MODE=existing \
SYNC_ENV=0 \
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
+12 -1
查看文件
@@ -21,6 +21,7 @@ ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs"
REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"
STAGING_DIR=""
RSYNC_PROTECT_ARGS=()
@@ -148,6 +149,11 @@ if [[ ! -f "${ROOT_DIR}/${EXTERNAL_DEPS_SMOKE_SCRIPT}" ]]; then
exit 1
fi
if [[ ! -f "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${REMOTE_PREFLIGHT_SCRIPT}" >&2
exit 1
fi
if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2
exit 1
@@ -160,7 +166,12 @@ echo ">>> 2. 从已验证提交生成不可变同步目录"
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")"
git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR"
echo ">>> 3. 创建远端目录"
echo ">>> 3. 同步前只读校验远端部署合同"
ssh "${DEPLOY_SERVER}" \
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' SYNC_ENV='${SYNC_ENV}' bash -s" \
< "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}"
echo ">>> 3.1 创建远端目录"
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
echo ">>> 4. 同步已验证提交到服务器"
@@ -0,0 +1,179 @@
{
"version": 1,
"categories": {
"template_rebuild": [
"AI_ASSIST_ENABLED",
"AI_BASE_URL",
"AI_MODEL",
"AI_PROVIDER",
"AI_TIMEOUT_MS",
"ANNOUNCEMENTS_ENABLED",
"APP_HOST_PORT",
"APP_ORIGIN",
"APP_TIMEZONE",
"ATTENDANCE_ENABLED",
"ATTENDANCE_UNIFIED_ENABLED",
"AUTH_CODE_REQUEST_COOLDOWN_SECONDS",
"AUTH_LOGIN_FAIL_MAX_ATTEMPTS",
"AUTH_LOGIN_FAIL_WINDOW_SECONDS",
"AUTH_LOGIN_LOCK_SECONDS",
"BUDGET_CONTROL_MODE",
"BUDGET_PRECHECK_ENABLED",
"CLIENT_DIST_DIR",
"CONFIDENTIAL_DOC_REFS_ENABLED",
"COOKIE_SECURE",
"CUTOVER_CONSOLE_ENABLED",
"DATABASE_URL",
"DELEGATION_REVOKE_CRON",
"DEV_SHOW_PASSWORD_RESET_CODE",
"EMAIL_BASE_URL",
"EMAIL_ENABLED",
"EMAIL_FROM",
"EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS",
"EMAIL_LOGIN_CODE_LOCK_SECONDS",
"EMAIL_LOGIN_CODE_MAX_ATTEMPTS",
"EMAIL_LOGIN_CODE_TTL_SECONDS",
"EMPLOYEE_LIFECYCLE_ENABLED",
"ERP_ASSET_EXPORT_ENDPOINT",
"ERP_ASSET_TIMEOUT_MS",
"ERP_JOURNAL_EXPORT_ENDPOINT",
"ERP_MASTER_DATA_ENDPOINT",
"ERP_MASTER_DATA_SYNC_CRON",
"ERP_MASTER_DATA_TIMEOUT_MS",
"ERP_PROCUREMENT_EXPORT_ENDPOINT",
"ERP_PROCUREMENT_TIMEOUT_MS",
"ERP_SYNC_ENABLED",
"ERP_SYNC_ENDPOINT",
"ERP_SYNC_PROVIDER",
"ERP_SYNC_TIMEOUT_MS",
"GLOBAL_SEARCH_ENABLED",
"HOST",
"IM_NOTIFIER_ENABLED",
"INTEGRATION_OUTBOX_BATCH_SIZE",
"INTEGRATION_OUTBOX_CRON",
"INTEGRATION_OUTBOX_MAX_ATTEMPTS",
"INTEGRATION_OUTBOX_RETRY_SECONDS",
"INVOICE_ARCHIVE_BATCH_SIZE",
"INVOICE_ARCHIVE_CRON",
"INVOICE_ARCHIVE_UNUSED_DAYS",
"INVOICE_VERIFICATION_ENABLED",
"JOBS_ENABLED",
"JOB_DRIVER",
"LEAVE_HOLIDAY_CALENDAR_ENABLED",
"MAX_UPLOAD_SIZE_BYTES",
"METRICS_ENABLED",
"MINIO_ROOT_USER",
"NODE_ENV",
"OCR_BATCH_SIZE",
"OCR_CRON_ENABLED",
"OCR_LOCK_TTL_SECONDS",
"OCR_MAX_ATTEMPTS",
"OCR_POLL_CRON",
"OCR_PROVIDER",
"OCR_TIMEOUT_MS",
"OIDC_ENABLED",
"OIDC_ISSUER_URL",
"OIDC_REDIRECT_URI",
"OIDC_SCOPES",
"OPENAPI_DOCS_ENABLED",
"OTEL_TRACES_ENABLED",
"PASSWORD_RESET_CODE_TTL_SECONDS",
"PASSWORD_RESET_FAIL_WINDOW_SECONDS",
"PASSWORD_RESET_LOCK_SECONDS",
"PASSWORD_RESET_MAX_ATTEMPTS",
"PASSWORD_RESET_REQUEST_MAX",
"PASSWORD_RESET_REQUEST_WINDOW_SECONDS",
"PAYMENT_BATCH_ENABLED",
"PG_BOSS_SCHEMA",
"PORT",
"PORTAL_HOME_ENABLED",
"POSTGRES_DATA_DIR",
"POSTGRES_DB",
"POSTGRES_USER",
"PRESENCE_BOARD_ENABLED",
"PROJECT_CLOSURE_CHECKLIST_ENABLED",
"PROJECT_COMMUNICATION_LOG_ENABLED",
"PROJECT_DOMAIN_ENABLED",
"PROJECT_LINKING_ENABLED",
"PUBLIC_SECURITY_HARDENING_ENABLED",
"RATE_LIMIT_ENABLED",
"RATE_LIMIT_LOGIN_MAX",
"RATE_LIMIT_LOGIN_WINDOW",
"S3_BUCKET",
"S3_ENDPOINT",
"S3_FORCE_PATH_STYLE",
"S3_REGION",
"SERVE_CLIENT_DIST",
"SHADOW_DATABASE_URL",
"SIGNED_URL_TTL_SECONDS",
"SMTP_HOST",
"SMTP_PORT",
"SMTP_SECURE",
"STORAGE_DIR",
"STORAGE_DRIVER",
"STORAGE_HOST_DIR",
"TENCENT_OCR_REGION",
"TODO_BOARD_ENABLED",
"TRAVEL_SYNC_ENABLED",
"TRIALRUN_ADMIN_EMAIL",
"TRIALRUN_ADMIN_EMPLOYEE_NO",
"TRIALRUN_ADMIN_HIRE_DATE",
"TRIALRUN_ADMIN_NAME",
"TRIALRUN_ADMIN_PHONE",
"TRIALRUN_ADMIN_TITLE",
"TRUST_PROXY_HOPS",
"VERIFICATION_MAX_ATTEMPTS",
"VERIFICATION_POLL_CRON",
"VERIFICATION_PROVIDER",
"VERIFICATION_RETRY_COOLDOWN_SECONDS",
"VERIFICATION_TIMEOUT_MS",
"WECOM_ALLOWED_RETURN_HOSTS",
"WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE",
"WECOM_APPROVAL_CALLBACK_URL",
"WECOM_CHECKIN_SYNC_ENABLED",
"WECOM_CONTACT_SYNC_ENABLED",
"WECOM_ENABLED",
"WECOM_ENTRY_ENABLED",
"WECOM_HR_MASTER_SYNC_ENABLED",
"WECOM_OAUTH_ENABLED",
"WECOM_OAUTH_ONBOARDING_ENABLED",
"WECOM_PC_WEB_LOGIN_ENABLED",
"WECOM_REDIRECT_URI",
"WEEKLY_REPORT_ENABLED",
"WORKFLOW_DEFINITION_ADMIN_ENABLED"
],
"external_reissue": [
"AI_AUTH_TOKEN",
"ERP_SYNC_AUTH_TOKEN",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"SMTP_PASS",
"SMTP_USER",
"TENCENT_SECRET_ID",
"TENCENT_SECRET_KEY",
"WECOM_AGENT_ID",
"WECOM_AGENT_SECRET",
"WECOM_CALLBACK_ENCODING_AES_KEY",
"WECOM_CALLBACK_TOKEN",
"WECOM_CHECKIN_SECRET",
"WECOM_CONTACT_DEFAULT_DEPARTMENT_ID",
"WECOM_CONTACT_SECRET",
"WECOM_CORP_ID",
"WECOM_HR_ASSISTANT_SECRET"
],
"direct_rotate": [
"AUTH_CODE_PEPPER",
"JWT_SECRET",
"MINIO_ROOT_PASSWORD",
"TRIALRUN_ADMIN_PASSWORD"
],
"downtime_reset": [
"POSTGRES_PASSWORD"
],
"preserve_original": [
"FIELD_ENCRYPTION_KEY"
]
}
}
+27
查看文件
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
echo "[remote-preflight][ERROR] $1" >&2
exit 1
}
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE POSTGRES_DATA_DIR STORAGE_HOST_DIR DEPLOY_DATA_MODE SYNC_ENV; do
[[ -n "${!key:-}" ]] || fail "缺少 $key"
done
if [[ "$DEPLOY_DATA_MODE" == "existing" ]]; then
[[ -d "$REMOTE_DIR" \
&& -f "$REMOTE_DIR/server/package.json" \
&& -f "$REMOTE_DIR/$COMPOSE_FILE" ]] \
|| fail "existing 模式要求 REMOTE_DIR 是现存仓库根目录"
[[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]] \
|| fail "existing 模式的数据目录合同未满足"
fi
if [[ "$SYNC_ENV" == "0" ]]; then
[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" ]] \
|| fail "SYNC_ENV=0 要求同步前已有普通远端 .env 文件"
fi
echo "[remote-preflight][PASS] 远端仓库、环境与数据合同通过"
+45 -10
查看文件
@@ -2,13 +2,43 @@
> 目标:按本文档在一台新机器完成 **启动 → 迁移 → 初始化管理员 → 验收 → 备份 → 恢复 → 回滚** 演练。
## 紧急恢复卡(单管理员离线版)
## 紧急恢复卡(双人密钥托管版)
适用情形:生产主机不可恢复、数据库或附件损坏,且已由负责人批准进入事故恢复。公司当前只有一名系统管理员,由该管理员维护并执行本卡;不得在聊天或工单中粘贴凭证、业务正文、真实目录或原始日志。
适用情形:生产主机不可恢复、环境文件丢失、数据库或附件损坏,且已由负责人批准进入事故恢复。系统管理员负责技术操作,公司负责人负责密钥取用复核;`preserve_original` 类必须确保二人均能从仓库外共享密码库或加密封存副本取用。不得在聊天、工单、命令历史或报告中粘贴凭证、业务正文、真实目录或原始日志。
1. 先暂停外部入口和新业务写入,不删除故障主机、旧数据目录或 ARM 归档;记录故障时间、最近成功批次编号类别和恢复负责人。
2. 在 ARM 的只读归档中选择最近一个 `complete` 批次,复核三文件、manifest、SHA-256 与来源标识;先复制到隔离恢复工作区,不直接对归档原件操作。
3. 在获批的新主机/隔离目标依次执行以下三个入口;尖括号值只从受控运维配置取得,不写回本文:
### A. 先冻结现场
1. 暂停外部入口和新业务写入,不删除故障主机、旧容器、旧数据目录或 ARM 归档;记录故障时间、最近成功批次编号类别、恢复负责人和复核人。
2. 若旧容器仍在运行,只允许按 `.env.example` 键名白名单提取缺失配置并原子写入 root-only 临时文件;不输出值,不把“容器还活着”当正式恢复机制。
3. 在 ARM 的只读归档中选择最近一个 `complete` 批次,复核三文件、manifest、SHA-256 与来源标识;先复制到隔离恢复工作区,不直接对归档原件操作。
### B. 重建环境配置
先运行 `node deploy/trialrun/validate-env-contract.js`,确认 `.env.example` 的全部键已在 `env-recovery-classification.json` 中恰好分类一次。恢复配置只按以下五类处理:
| 类别 | 丢失影响与恢复方式 | 恢复后验证 |
| --- | --- | --- |
| `template_rebuild` | 端口、路径、开关、TTL、公开地址等,从 `.env.example`、本 runbook 和已批准基础设施合同重填;不能从旧文档复制已过时值 | 配置预检、Compose config、路径/端口和功能开关基线 |
| `external_reissue` | 企微、腾讯云、OIDC、SMTP、S3、ERP、AI 等外部标识或凭据,由对应平台管理员重新获取或签发;不从数据库、日志或 Git 反推 | external deps smoke、对应平台只读配置核对,必要时人工真机验证 |
| `direct_rotate` | JWT、验证码 pepper、自建 MinIO 管理口令、试运行管理员口令等生成新值;必须接受全部会话、验证码或目标账号凭据失效 | 新旧会话边界、登录/重置、runtime smoke;旧值不得继续使用 |
| `downtime_reset` | PostgreSQL 角色口令无法从 `pg_authid` 反推明文;保留 PGDATA 时按下述隔离停机步骤重置,不称为“找回” | 原认证配置字节级恢复、数据库不暴露宿主端口、migration status、应用登录与备份 |
| `preserve_original` | **最高风险**。`FIELD_ENCRYPTION_KEY` 原值丢失会使既有银行账号、身份证号和通知渠道密文永久不可读;只能从双人可取的共享密码库/加密封存副本恢复,禁止生成新值冒充恢复成功 | 用合成探针验证加解密能力,并由授权页面抽样确认既有密文仍可解;不得输出明文 |
分类描述恢复方式,不代表配置值可以公开;例如 `DATABASE_URL` 在数据库口令重置后按模板重建,但仍是只允许进入 root-only 环境文件的 Secret。`TRIALRUN_ADMIN_PASSWORD` 对既有管理员不会因改 env 自动生效,必须通过现有受控密码重置入口轮换;新初始化时才由 env 提供初始值。
`FIELD_ENCRYPTION_KEY` 当前密文只有 `v1` 格式版本,没有 key id 或 keyring;在独立轮换设计完成前必须保留原值。若封存副本无法取得,立即停止恢复并上报数据不可读影响,不要求员工重新提交敏感资料前先宣称系统已恢复。
数据库口令重置仅在 PGDATA 尚存、外部入口已暂停且负责人批准的停机窗口执行:
1. 停止 server、worker、init 和数据库容器,确认没有进程仍挂载该 PGDATA;复制并校验当前 `pg_hba.conf`,记录校验状态而非文件内容。
2. 使用相同 PostgreSQL 大版本在隔离容器中挂载该 PGDATA;不得发布 TCP 端口。优先使用 PostgreSQL 单用户/本地 socket 能力重置目标角色口令。
3. 如环境限制必须临时调整认证,只允许增加本地 socket 的临时 `trust`,禁止任何 `host ... trust`,并通过交互式 `\password <role>` 输入新值,避免把口令写入命令行和日志。
4. 停止隔离数据库,把 `pg_hba.conf` 按备份逐字节恢复并复核校验;同步更新恢复环境中的 `POSTGRES_PASSWORD` 与派生的 `DATABASE_URL`,文件保持 root-only。
5. 按正常 Compose 合同启动数据库与应用,证明数据库无宿主发布端口、认证配置无临时改动、migration status、`/ready`、runtime/external smoke、登录和自动备份均通过。任一项失败都保持外部入口关闭。
### C. 恢复数据与应用
在获批的新主机/隔离目标依次执行以下三个入口;尖括号值只从受控运维配置取得,不写回本文:
```bash
RESTORE_SCOPE=shadow RESTORE_CONFIRM_DATABASE=<shadow-db> <explicit-db-env> \
@@ -18,10 +48,11 @@ RESTORE_SCOPE=shadow ./scripts/restore-storage.sh \
curl -fsS <shadow-ready-url>
```
4. 复核 `/ready`、运行构建标识、成功 migration 数、组织/权限/报销/付款等关键表计数、附件普通文件数及 DB/附件关联;任何一项不一致都停止切换。
5. 真实生产恢复仍是 **EXTERNAL_GATED**:另建或更新获批 Trellis task,冻结新数据目录、回滚点和停机窗口后才切换。恢复后保留 ARM 归档和故障主机原数据,记录 RTO/RPO 与结论。
复核 `/ready`、运行构建标识、成功 migration 数、组织/权限/报销/付款等关键表计数、附件普通文件数及 DB/附件关联;任何一项不一致都停止切换。
唯一系统管理员必须每季度按本卡在 ARM 隔离目标完成人工影子恢复;自动 timer 只提醒,不自动恢复。可在 ARM 和离线介质各保留一份脱敏副本,但不得写入凭证、真实目录或原始日志。
真实生产恢复仍是 **EXTERNAL_GATED**:另建或更新获批 Trellis task,冻结新数据目录、env 外置路径、回滚点和停机窗口后才切换。恢复后保留 ARM 归档和故障主机原数据,记录 RTO/RPO 与结论。
系统管理员与公司负责人必须每季度共同确认 `preserve_original` 封存项仍可由二人分别取用;至少一名操作人按本卡在 ARM 隔离目标完成人工影子恢复。只记录角色、时间和 PASS/FAIL,不记录密码库地址、账号、恢复码或 Secret。自动 timer 只提醒,不自动恢复。
## 0A. 如果官网 `hyb-web` 已在同机运行(推荐方案)
- 官网继续保持 `hyinda.com / www.hyinda.com` 静态托管,不要改官网目录和配置。
@@ -40,6 +71,7 @@ curl -fsS <shadow-ready-url>
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。
- 部署目录通过 `REMOTE_DIR` 显式传入。
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
- 已有部署优先执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`;包装脚本只从受控 automatic-backup systemd unit 读取 allowlist 参数且不回显路径。不得把 Compose `com.docker.compose.project.working_dir` 当仓库根目录,该标签可能指向 `deploy/trialrun` 配置目录。
- 远端容器编排当前实际为 `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`
@@ -121,7 +153,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
说明:
- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。`seed:config` 仅在审批规则表为空时创建默认三档;已有任意审批规则时必须全部保留,生产规则变更另走获批的原子事务。
- 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。
- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。
- 首次创建试运行管理员前必须通过受控 `.env` 提供 `TRIALRUN_ADMIN_PASSWORD`;seed 不生成或输出口令,登录标识也不进入部署日志。
- 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。
- 如确需导入,仅导入最小必要主数据:部门、员工、角色、在用资产、活跃项目、合同元数据。
- 历史审批单、通知历史、审计日志、聊天记录以及涉密正文/附件继续保留在原系统、文件盘或离线档案中。
@@ -392,7 +424,10 @@ systemctl status hyb-oa-public-app-health.service --no-pager
步骤:
1) 先做备份(见 E14 脚本)
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 文件带入发布。`REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;任一方向嵌套都会在 SSH 和 `rsync --delete` 前阻断。位于同步树内的 `REMOTE_ENV_FILE` 按实际相对路径动态保护。
2) 已有部署从本地仓库执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`(推荐);它从受控 systemd unit 读取既有 `REMOTE_DIR`、env、Compose project 与数据目录合同,不从 Docker 标签猜路径,也不回显参数值。首次初始化或 unit 不存在时,才使用完整显式参数执行 `deploy.sh`。
- `deploy.sh` 只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
- 在任何 rsync 前,远端 preflight 必须确认 existing 目标同时含 `server/package.json` 与 Compose 文件、PGDATA 含 `PG_VERSION`、storage 存在,且 `SYNC_ENV=0` 的 `.env` 是普通文件。失败即停止,不能先同步再验证。
- `REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;位于同步树内的 `REMOTE_ENV_FILE` 按精确相对路径动态保护。
- 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。
- 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
+145
查看文件
@@ -8,6 +8,10 @@ const configPath = path.join(rootDir, 'server/src/config.js');
const envExamplePath = path.resolve(
process.env.ENV_CONTRACT_ENV_EXAMPLE_PATH || path.join(rootDir, 'deploy/trialrun/.env.example'),
);
const recoveryClassificationPath = path.resolve(
process.env.ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH
|| path.join(rootDir, 'deploy/trialrun/env-recovery-classification.json'),
);
const composePath = path.resolve(
process.env.ENV_CONTRACT_COMPOSE_PATH || path.join(rootDir, 'deploy/trialrun/docker-compose.yml'),
);
@@ -16,6 +20,9 @@ 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 deployExistingScriptPath = path.join(rootDir, 'deploy/trialrun/deploy-existing.sh');
const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh');
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
const timerScriptPath = path.resolve(
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|| path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'),
@@ -54,6 +61,13 @@ const requiredBuildArgs = {
worker: ['BUILD_SHA', 'VITE_BUILD_SHA'],
init: ['BUILD_SHA', 'VITE_BUILD_SHA'],
};
const recoveryCategories = [
'template_rebuild',
'external_reissue',
'direct_rotate',
'downtime_reset',
'preserve_original',
];
function readFile(filePath) {
return fs.readFileSync(filePath, 'utf8');
@@ -81,6 +95,86 @@ function extractEnvExampleKeys(content) {
return keys;
}
function validateRecoveryClassification(content, envKeys) {
const validationErrors = [];
let parsed;
try {
parsed = JSON.parse(content);
} catch {
return { errors: ['环境恢复分类清单必须是有效 JSON'], keyCount: 0 };
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { errors: ['环境恢复分类清单顶层必须是对象'], keyCount: 0 };
}
const topLevelKeys = Object.keys(parsed).sort();
if (topLevelKeys.join(',') !== 'categories,version') {
validationErrors.push('环境恢复分类清单顶层只允许 version 和 categories');
}
if (parsed.version !== 1) {
validationErrors.push('环境恢复分类清单 version 必须为 1');
}
if (!parsed.categories || typeof parsed.categories !== 'object' || Array.isArray(parsed.categories)) {
return { errors: [...validationErrors, '环境恢复分类清单 categories 必须是对象'], keyCount: 0 };
}
const actualCategories = Object.keys(parsed.categories).sort();
const expectedCategories = [...recoveryCategories].sort();
for (const category of expectedCategories) {
if (!actualCategories.includes(category)) {
validationErrors.push(`环境恢复分类清单缺少类别 ${category}`);
}
}
for (const category of actualCategories) {
if (!expectedCategories.includes(category)) {
validationErrors.push(`环境恢复分类清单存在未知类别 ${category}`);
}
}
const occurrences = new Map();
for (const category of expectedCategories) {
const entries = parsed.categories[category];
if (!Array.isArray(entries)) {
validationErrors.push(`环境恢复类别 ${category} 必须是键名数组`);
continue;
}
for (const entry of entries) {
if (typeof entry !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry)) {
validationErrors.push(`环境恢复类别 ${category} 含非法键名或疑似值`);
continue;
}
const categories = occurrences.get(entry) ?? [];
categories.push(category);
occurrences.set(entry, categories);
}
}
for (const [key, categories] of occurrences) {
if (categories.length !== 1) {
validationErrors.push(`环境恢复键 ${key} 重复分类: ${categories.join(', ')}`);
}
if (!envKeys.has(key)) {
validationErrors.push(`环境恢复分类包含 .env.example 外键 ${key}`);
}
}
for (const key of envKeys) {
if (!occurrences.has(key)) {
validationErrors.push(`环境恢复分类缺少 ${key}`);
}
}
const preserveOriginal = parsed.categories.preserve_original;
if (!Array.isArray(preserveOriginal) || !preserveOriginal.includes('FIELD_ENCRYPTION_KEY')) {
validationErrors.push('FIELD_ENCRYPTION_KEY 必须归入 preserve_original');
}
const downtimeReset = parsed.categories.downtime_reset;
if (!Array.isArray(downtimeReset) || !downtimeReset.includes('POSTGRES_PASSWORD')) {
validationErrors.push('POSTGRES_PASSWORD 必须归入 downtime_reset');
}
return { errors: validationErrors, keyCount: occurrences.size };
}
function extractServiceBlock(composeContent, serviceName) {
const lines = composeContent.split(/\r?\n/);
let capture = false;
@@ -151,10 +245,14 @@ function fail(message, errors) {
const configContent = readFile(configPath);
const envExampleContent = readFile(envExamplePath);
const recoveryClassificationContent = readFile(recoveryClassificationPath);
const composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
const deployScriptContent = readFile(deployScriptPath);
const deployExistingScriptContent = readFile(deployExistingScriptPath);
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
const dockerfileContent = readFile(dockerfilePath);
const timerScriptContent = readFile(timerScriptPath);
const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath);
const offhostReceiverInstallerContent = readFile(offhostReceiverInstallerPath);
@@ -171,6 +269,12 @@ const configKeys = extractConfigEnvKeys(configContent);
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
const errors = [];
const recoveryClassification = validateRecoveryClassification(
recoveryClassificationContent,
envExampleKeys,
);
errors.push(...recoveryClassification.errors);
for (const key of configKeys) {
if (!deploymentInjectedEnvKeys.has(key) && !envExampleKeys.has(key)) {
errors.push(`.env.example 缺少 ${key}`);
@@ -439,12 +543,52 @@ for (const requiredFragment of [
'DEPLOY_DATA_MODE" != "existing"',
'DEPLOY_DATA_MODE" != "initialize"',
'"$POSTGRES_DATA_DIR/PG_VERSION"',
'>>> 3. 同步前只读校验远端部署合同',
'REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"',
]) {
if (!deployScriptContent.includes(requiredFragment)) {
errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`);
}
}
if (deployScriptContent.indexOf('>>> 3. 同步前只读校验远端部署合同')
> deployScriptContent.indexOf('rsync -az --delete')) {
errors.push('deploy.sh 必须在 rsync 前执行远端只读 preflight');
}
for (const requiredFragment of [
'systemctl show',
'DEPLOY_DATA_MODE=existing',
'SYNC_ENV=0',
'路径与标识不回显',
]) {
if (!deployExistingScriptContent.includes(requiredFragment)) {
errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`);
}
}
for (const requiredFragment of [
'REMOTE_DIR 是现存仓库根目录',
'SYNC_ENV=0 要求同步前已有普通远端 .env 文件',
'"$POSTGRES_DATA_DIR/PG_VERSION"',
]) {
if (!remotePreflightScriptContent.includes(requiredFragment)) {
errors.push(`preflight-remote.sh 缺少同步前护栏: ${requiredFragment}`);
}
}
for (const [earlier, later] of [
['RUN apk add --no-cache bash curl', 'ARG VITE_BUILD_SHA'],
['RUN cd client && npm ci', 'ARG VITE_BUILD_SHA'],
['RUN cd server && npm ci', 'ARG BUILD_SHA'],
]) {
if (dockerfileContent.indexOf(earlier) === -1
|| dockerfileContent.indexOf(later) === -1
|| dockerfileContent.indexOf(earlier) > dockerfileContent.indexOf(later)) {
errors.push(`Dockerfile 缓存顺序无效: ${earlier} 必须早于 ${later}`);
}
}
for (const { filePath, content } of nginxContents) {
const relativePath = path.relative(rootDir, filePath);
if (content.includes('$proxy_add_x_forwarded_for')) {
@@ -492,4 +636,5 @@ if (errors.length) {
console.log('[R26][PASS] 部署环境契约校验通过');
console.log(`- config.js env 键数: ${configKeys.length}`);
console.log(`- .env.example env 键数: ${envExampleKeys.size}`);
console.log(`- 环境恢复分类键数: ${recoveryClassification.keyCount}`);
console.log(`- 已检查服务: ${requiredServices.join(', ')}`);
+3 -1
查看文件
@@ -1,7 +1,7 @@
# 领域事件清单(P19 / P19+IM-1 / P19+ERP-3 / P19+AI-1 / P20+TRAVEL-1 / P20+TRAVEL-2 / P20+TRAVEL-3)
**文档状态**:技术参考和历史证据,不是当前 MVP 需求事实源。
**快照日期**:2026-03-10
**快照日期**:2026-08-01
**目标**:冻结 `hyb-oa` 当前采购、资产与差旅域的标准领域事件名,并补充 `P19+IM-1` 已实际消费的 IM 通知类型清单,以及 `P19+ERP-1 / ERP-3 / AI-1 / TRAVEL-1` 已实际落地的消费映射,作为后续 ERP / IM / AI / TRAVEL 适配的事实源。
**不得误读**:本文用于追溯 Outbox、IM、ERP、AI、TRAVEL 等历史集成边界;当前 10 人以下公司 MVP 范围以 [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)、模块规约和最新 `issues/*.csv` 为准。
@@ -85,6 +85,8 @@
| `reimbursement.rejected` | 报销驳回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 |
| `reimbursement.returned` | 报销退回 | `reimbursement_form` | `/reimbursements/:id` | 结果通知 |
| `reimbursement.paid` | 报销付款完成 | `reimbursement_form` | `/reimbursements/:id` | 财务结果通知 |
| `payment_batch.review_requested` | 报销付款批次创建,责任转给非制单复核人 | `payment_batch` | `/payment-batches?id=<id>` | 待办通知;复用 `reimbursement` 通知域 |
| `payment_batch.execution_requested` | 付款复核通过,责任转回实际付款人 | `payment_batch` | `/payment-batches?id=<id>` | 待办通知;优先通知仍可付款的制单人 |
| `leave.approved` | 请假审批通过 | `leave_request` | `/leave-requests` | 结果通知 |
| `leave.rejected` | 请假驳回 | `leave_request` | `/leave-requests` | 结果通知 |
| `purchase_request.approved` | 采购审批通过 | `purchase_request` | `/purchase-requests` | 结果通知 |
@@ -17,6 +17,8 @@ const NOTIFICATION_TYPES = Object.freeze({
reimbursementRejected: 'reimbursement.rejected',
reimbursementReturned: 'reimbursement.returned',
reimbursementPaid: 'reimbursement.paid',
paymentBatchReviewRequested: 'payment_batch.review_requested',
paymentBatchExecutionRequested: 'payment_batch.execution_requested',
leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected',
attendanceCorrectionApproved: 'attendance_correction.approved',
@@ -39,6 +41,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.reimbursementRejected,
NOTIFICATION_TYPES.reimbursementReturned,
NOTIFICATION_TYPES.reimbursementPaid,
NOTIFICATION_TYPES.paymentBatchReviewRequested,
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
NOTIFICATION_TYPES.leaveApproved,
NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.attendanceCorrectionApproved,
@@ -61,6 +65,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.reimbursementRejected]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.reimbursementReturned]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchReviewRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.paymentBatchExecutionRequested]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
+150 -4
查看文件
@@ -11,6 +11,7 @@ const { parseDateOnlyString } = require("../utils/date");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { createNotificationsWithRecords } = require("../services/notifications");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const { resolvePermissionsForRoles, hasPermission } = require("../constants/permissions");
const { sendNotificationEmailSafe } = require("../services/email");
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
@@ -247,6 +248,118 @@ function ensureBatchReviewPermission(fastify, request) {
}
}
function roleCodesOf(employee) {
return (employee?.roles ?? []).map((role) => role.role_code).filter(Boolean);
}
function employeeHasRolePermission(employee, permissionCode) {
return hasPermission(resolvePermissionsForRoles(roleCodesOf(employee)), permissionCode);
}
async function resolvePaymentReviewRecipientIds(tx, makerId) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: makerId },
roles: { some: { role_code: { in: ["manager", "ceo"] } } },
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
return candidates
.filter((employee) => employeeHasRolePermission(employee, "payment.batch.review"))
.map((employee) => employee.id);
}
async function resolvePaymentExecutionRecipientIds(tx, { makerId, reviewerId }) {
const candidates = await tx.employee.findMany({
where: {
status: "active",
id: { not: reviewerId },
OR: [
{ id: makerId },
{ roles: { some: { role_code: { in: ["finance", "finance_mgr"] } } } },
],
},
orderBy: [{ employee_no: "asc" }, { id: "asc" }],
select: {
id: true,
roles: { select: { role_code: true } },
},
});
const eligible = candidates.filter((employee) =>
employee.id !== reviewerId && employeeHasRolePermission(employee, "payment.batch.manage"),
);
const activeMaker = eligible.find((employee) => employee.id === makerId);
if (activeMaker) {
return [activeMaker.id];
}
return eligible
.filter((employee) => roleCodesOf(employee).some((roleCode) => ["finance", "finance_mgr"].includes(roleCode)))
.map((employee) => employee.id);
}
async function createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type,
title,
body,
batchId,
actorId,
}) {
const notifications = await createNotificationsWithRecords(tx, {
recipientEmployeeIds,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
});
return {
notifications,
type,
title,
body,
targetType: "payment_batch",
targetId: batchId,
targetPath: `/payment-batches?id=${encodeURIComponent(batchId)}`,
actorId,
};
}
async function dispatchPaymentResponsibilityNotification(fastify, request, task) {
if (!task?.notifications?.length) {
return;
}
try {
await flushNotificationDispatchTasks({
prisma: fastify.prisma,
config: fastify.config,
logger: fastify.log,
requestId: request.id,
tasks: [task],
});
} catch (error) {
fastify.log.error(
{
err: error,
requestId: request.id,
notificationType: task.type,
targetType: task.targetType,
targetId: task.targetId,
},
"Failed to dispatch payment responsibility notification",
);
}
}
function buildBatchNo() {
const now = new Date();
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
@@ -1057,7 +1170,7 @@ async function paymentBatchesRoutes(fastify) {
}
const entityIds = Array.from(new Set(body.entityIds)).sort();
const created = await fastify.prisma.$transaction(async (tx) => {
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
await lockPaymentEntities(tx, body.entityType, entityIds);
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
if (snapshots.length !== entityIds.length) {
@@ -1130,9 +1243,24 @@ async function paymentBatchesRoutes(fastify) {
}),
});
return loadBatchWithItems(tx, batch.id);
const recipientEmployeeIds = await resolvePaymentReviewRecipientIds(tx, request.employee.id);
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchReviewRequested,
title: "待付款复核",
body: "付款安排已创建,请完成付款复核。",
batchId: batch.id,
actorId: request.employee.id,
});
return {
batch: await loadBatchWithItems(tx, batch.id),
notificationTask: nextNotificationTask,
};
});
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
});
@@ -1590,7 +1718,7 @@ async function paymentBatchesRoutes(fastify) {
const batchId = parseId(request.params?.id);
const body = parseBody(REVIEW_SCHEMA, request.body);
const result = await fastify.prisma.$transaction(async (tx) => {
const { batch: result, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
const batchRisk = summarizeBatchRisk(batch);
@@ -1659,9 +1787,27 @@ async function paymentBatchesRoutes(fastify) {
}),
});
return reviewed;
const recipientEmployeeIds = await resolvePaymentExecutionRecipientIds(tx, {
makerId: batch.created_by,
reviewerId: request.employee.id,
});
const nextNotificationTask = await createPaymentResponsibilityNotification(tx, {
recipientEmployeeIds,
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
title: "待完成付款",
body: "付款复核已通过,请完成付款并登记回单。",
batchId,
actorId: request.employee.id,
});
return {
batch: reviewed,
notificationTask: nextNotificationTask,
};
});
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
return sendData(reply, request, { item: mapBatch(result, []) });
});
+16 -13
查看文件
@@ -333,8 +333,8 @@ const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" },
{ key: "payment_batch_review", label: "付款批次复核" },
{ key: "payment_and_receipt", label: "付款与回单" },
{ key: "payment_batch_review", label: "付款复核" },
{ key: "payment_and_receipt", label: "付款完成" },
]);
function isoDateTimeOrNull(value) {
@@ -511,31 +511,34 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
if (!batch) {
Object.assign(batchReviewStage, reimbursement.status === "approved"
? { state: "current", result: "待建批次", detail: "待财务创建付款批次" }
: { detail: "待财务创建付款批次" });
paymentStage.detail = "待付款批次复核后处理";
? { state: "current", result: "待安排", detail: "待财务安排付款" }
: { detail: "待财务安排付款" });
paymentStage.detail = "待付款复核后由财务登记,非审批环节";
} else if (batch.status === "draft") {
Object.assign(batchReviewStage, {
state: "current",
result: "待复核",
detail: "所在付款批次待非制单人复核",
detail: "待非制单人复核付款",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待付款复核通过后由财务登记,非审批环节",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, { detail: "待所在付款批次复核通过", targetHref: batchTargetHref });
} else if (["reviewed", "executed", "failed"].includes(batch.status)) {
Object.assign(batchReviewStage, {
state: "completed",
actorName: paymentActorNames.get(batch.reviewed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.reviewed_at),
result: "通过",
detail: batch.status === "reviewed" ? "所在付款批次已复核,待执行付款" : null,
detail: batch.status === "reviewed" ? "付款复核已通过" : null,
targetHref: batchTargetHref,
});
if (batch.status === "reviewed") {
Object.assign(paymentStage, {
state: "current",
result: "待付款",
detail: "所在付款批次已复核,待执行付款",
detail: "待财务完成付款并登记回单,非审批环节",
targetHref: batchTargetHref,
});
} else if (batch.status === "failed") {
@@ -544,7 +547,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
actorName: paymentActorNames.get(batch.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.executed_at),
result: "付款失败",
detail: "所在付款批次执行失败,待财务重新处理",
detail: "付款失败,待财务重新处理,非审批环节",
targetHref: batchTargetHref,
});
}
@@ -555,11 +558,11 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
occurredAt: isoDateTimeOrNull(batch.cancelled_at),
result: "批次已取消",
comment: batch.cancelled_reason ?? null,
detail: "所在付款批次已取消,待财务重新创建",
detail: "付款安排已取消,待财务重新处理",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待财务重新创建付款批次",
detail: "待财务重新安排付款,非审批环节",
targetHref: batchTargetHref,
});
}
@@ -571,7 +574,7 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at),
result: "已付款",
detail: receiptCount > 0 ? "付款与回单已留存" : "付款已完成,回单待留存",
detail: receiptCount > 0 ? "付款和回单已留存" : "付款已完成,回单待留存",
targetHref: batchTargetHref,
});
}
+6 -15
查看文件
@@ -1,7 +1,5 @@
require("dotenv/config");
const crypto = require("node:crypto");
const { createPrismaClient } = require("../prismaClient");
const { hashPassword } = require("../utils/crypto");
const { validatePassword } = require("../utils/passwordPolicy");
@@ -94,12 +92,6 @@ async function ensureDepartment(prisma, payload) {
});
}
function generateOneTimePassword() {
// 确保满足 passwordPolicy:>=12 + 小写 + 大写 + 数字
const suffix = crypto.randomBytes(6).toString("hex");
return `TrialrunA1${suffix}`;
}
async function ensureFeatureFlag(prisma, payload) {
const now = new Date();
const existing = await prisma.featureFlag.findUnique({
@@ -248,7 +240,10 @@ async function ensureAdminEmployee(prisma, params) {
return { employeeId: existing.id, created: false, password: null };
}
const password = params.password || generateOneTimePassword();
if (!params.password) {
throw new Error('TRIALRUN_ADMIN_PASSWORD is required when creating the trialrun admin; credentials are never generated into deployment logs');
}
const password = params.password;
const errors = validatePassword(password);
if (errors.length) {
throw new Error(`Invalid TRIALRUN_ADMIN_PASSWORD: ${errors.join("; ")}`);
@@ -348,9 +343,7 @@ async function main() {
// eslint-disable-next-line no-console
console.log("Seeded trialrun admin:");
// eslint-disable-next-line no-console
console.log(`- email: ${adminEmail}`);
// eslint-disable-next-line no-console
console.log(`- phone: ${adminPhone}`);
console.log("- login identifiers: configured");
// eslint-disable-next-line no-console
console.log(`- created: ${result.created}`);
// eslint-disable-next-line no-console
@@ -361,9 +354,7 @@ async function main() {
}
if (result.password) {
// eslint-disable-next-line no-console
console.log(`- one-time password: ${result.password}`);
// eslint-disable-next-line no-console
console.log(" (请立刻保存,并在首次登录后由管理员重置/更换密码)");
console.log("- initial password: supplied by approved configuration; not logged");
} else {
// eslint-disable-next-line no-console
console.log("- password: unchanged");
+7 -7
查看文件
@@ -31,7 +31,7 @@ function evaluateWecom(config) {
return {
state: 'degraded',
detail: `企业微信基础配置已启用,但入口=${config.wecomEntryEnabled ? 'on' : 'off'} oauth=${config.wecomOauthEnabled ? 'on' : 'off'}`,
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`],
checks: ['corpId=present', 'agentId=present'],
};
}
@@ -39,9 +39,9 @@ function evaluateWecom(config) {
state: 'ready',
detail: '企业微信入口与免登配置齐全',
checks: [
`corpId=${config.wecomCorpId}`,
`agentId=${config.wecomAgentId}`,
`redirectUri=${config.wecomRedirectUri}`,
'corpId=present',
'agentId=present',
'redirectUri=present',
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
],
};
@@ -66,7 +66,7 @@ function evaluateEmail(config) {
return {
state: 'ready',
detail: 'SMTP 基础配置齐全(仅做配置契约校验,不发真实邮件)',
checks: [`from=${config.emailFrom}`, `host=${config.smtpHost}`, `port=${config.smtpPort}`, `secure=${config.smtpSecure}`],
checks: ['from=present', 'host=present', 'port=valid', `secure=${config.smtpSecure}`],
};
}
@@ -88,7 +88,7 @@ function evaluateOcr(config) {
return {
state: 'ready',
detail: '腾讯 OCR 配置齐全',
checks: [`provider=${provider}`, `region=${config.tencentOcrRegion}`],
checks: [`provider=${provider}`, 'credentials=present', 'region=present'],
};
}
@@ -112,7 +112,7 @@ function evaluateBridge(config) {
return {
state: 'ready',
detail: '消息回流桥接配置齐全',
checks: [`appOrigin=${config.appOrigin}`],
checks: ['appOrigin=present'],
};
}
+2 -2
查看文件
@@ -203,9 +203,9 @@ async function main() {
});
}
assertCondition(Boolean(adminEmployee), `未找到 active admin 员工:${adminEmail}`);
assertCondition(Boolean(adminEmployee), '未找到 active admin 员工');
assertCondition(adminEmployee.status === 'active' && !adminEmployee.archived_at, `active admin 状态异常:${adminEmployee.status}`);
console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.employee_no}`);
console.log('[PASS] 已找到 active admin 员工');
const workspace = await getWorkspaceSetting(prisma);
assertWorkspaceSetting(workspace);
@@ -20,4 +20,21 @@ describe("notification types", () => {
NOTIFICATION_DOMAIN_SCOPES.workflow,
);
});
it("routes payment responsibility notifications through the reimbursement IM domain", () => {
expect(IM_SUPPORTED_NOTIFICATION_TYPES).toEqual(
expect.arrayContaining([
NOTIFICATION_TYPES.paymentBatchReviewRequested,
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
]),
);
expect(resolveNotificationDomainScope(
NOTIFICATION_TYPES.paymentBatchReviewRequested,
"payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
expect(resolveNotificationDomainScope(
NOTIFICATION_TYPES.paymentBatchExecutionRequested,
"payment_batch",
)).toBe(NOTIFICATION_DOMAIN_SCOPES.reimbursement);
});
});
@@ -211,6 +211,10 @@ function buildPrisma(options = {}) {
},
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
reservedEntityIds: options.reservedEntityIds ?? [],
employees: options.employees ?? [
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
],
invoice: {
linked_reimbursement_id: REIMBURSEMENT_ID,
status: "bound",
@@ -421,6 +425,9 @@ function buildPrisma(options = {}) {
auditLog: {
create: vi.fn(async () => ({ id: "audit-1" })),
},
employee: {
findMany: vi.fn(async () => state.employees.map((employee) => ({ ...employee }))),
},
$queryRaw: vi.fn(async () => [{ id: BATCH_ID }]),
};
@@ -657,6 +664,33 @@ describe("payment batch reimbursement routes", () => {
expect(response.statusCode).toBe(409);
expect(response.json().error.details.review).toBe("maker_checker_conflict");
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("rejects repeated payment review without creating another responsibility notification", async () => {
const { app } = await buildApp({
actor: {
id: REVIEWER_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.review"],
roleCodes: ["manager"],
},
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/review`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.review).toBe("batch_not_reviewable");
expect(createNotificationsWithRecordsMock).not.toHaveBeenCalled();
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
@@ -756,6 +790,9 @@ describe("payment batch reimbursement routes", () => {
});
it("allows one manager review for a high-amount batch and does not require a second reviewer", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-execute-1", recipient_employee_id: MAKER_ID },
]);
const { app } = await buildApp({
actor: {
id: REVIEWER_ID,
@@ -783,7 +820,7 @@ describe("payment batch reimbursement routes", () => {
payload: { comment: "已核对高额付款对象和金额" },
});
expect(response.statusCode).toBe(200);
expect(response.statusCode, response.body).toBe(200);
expect(response.json().data.item).toMatchObject({
status: "reviewed",
highRisk: true,
@@ -794,6 +831,65 @@ describe("payment batch reimbursement routes", () => {
reviewerPermission: "payment.batch.review",
},
});
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
recipientEmployeeIds: [MAKER_ID],
type: "payment_batch.execution_requested",
targetType: "payment_batch",
targetId: BATCH_ID,
}),
);
expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith(
expect.objectContaining({
tasks: [expect.objectContaining({
type: "payment_batch.execution_requested",
targetPath: `/payment-batches?id=${BATCH_ID}`,
})],
}),
);
await app.close();
});
it("falls back to another active finance payment manager after review when the maker is unavailable", async () => {
const fallbackFinanceId = "dddddddd-dddd-4ddd-8ddd-dddddddddddd";
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-execute-fallback", recipient_employee_id: fallbackFinanceId },
]);
const { app } = await buildApp({
actor: {
id: REVIEWER_ID,
department_id: "dept-1",
permissionCodes: ["payment.batch.review"],
roleCodes: ["manager"],
},
batch: {
status: "draft",
reviewed_at: null,
reviewed_by: null,
},
employees: [
{ id: fallbackFinanceId, employee_no: "FIN002", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
],
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/review`,
headers: { authorization: "Bearer test" },
payload: {},
});
expect(response.statusCode, response.body).toBe(200);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
recipientEmployeeIds: [fallbackFinanceId],
type: "payment_batch.execution_requested",
}),
);
await app.close();
});
@@ -898,6 +994,98 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("notifies active non-maker payment reviewers after creating a reimbursement batch", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-1", recipient_employee_id: REVIEWER_ID },
]);
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "本周付款",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
tx,
expect.objectContaining({
recipientEmployeeIds: [REVIEWER_ID],
type: "payment_batch.review_requested",
targetType: "payment_batch",
targetId: BATCH_ID,
}),
);
expect(flushNotificationDispatchTasksMock).toHaveBeenCalledWith(
expect.objectContaining({
tasks: [expect.objectContaining({
type: "payment_batch.review_requested",
targetPath: `/payment-batches?id=${BATCH_ID}`,
})],
}),
);
await app.close();
});
it("creates no payment review notification when no eligible non-maker reviewer exists", async () => {
const { app } = await buildApp({
employees: [
{ id: ACTOR_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
],
});
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "无人复核批次",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(createNotificationsWithRecordsMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ recipientEmployeeIds: [] }),
);
expect(flushNotificationDispatchTasksMock).not.toHaveBeenCalled();
await app.close();
});
it("keeps a created batch when payment review notification delivery fails", async () => {
createNotificationsWithRecordsMock.mockResolvedValueOnce([
{ id: "notif-review-failed", recipient_employee_id: REVIEWER_ID },
]);
flushNotificationDispatchTasksMock.mockRejectedValueOnce(new Error("synthetic dispatch failure"));
const { app, state } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
name: "投递失败不回滚",
entityIds: [REIMBURSEMENT_ID],
},
});
expect(response.statusCode).toBe(201);
expect(state.batch.status).toBe("draft");
expect(state.batch.items).toHaveLength(1);
await app.close();
});
it("adds a non-identifying random suffix to concurrent-style batch numbers", async () => {
const { app, tx } = await buildApp();
const payload = {
@@ -322,7 +322,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect.objectContaining({ key: "submitted", state: "completed", actorName: "李四", result: "已提交" }),
expect.objectContaining({ key: "approval_manager-task", label: "主管审批", state: "completed", actorName: "王经理", result: "通过" }),
expect.objectContaining({ key: "approval_finance-task", label: "财务审批", state: "current", actorName: "财务专员", result: "处理中" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务创建付款批次" }),
expect.objectContaining({ key: "payment_batch_review", state: "pending", actorName: null, detail: "待财务安排付款" }),
expect.objectContaining({ key: "payment_and_receipt", state: "pending", actorName: null }),
]);
@@ -598,11 +598,11 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
});
it.each([
["draft", "current", "pending", "待所在付款批次复核通过"],
["reviewed", "completed", "current", "所在付款批次已复核,待执行付款"],
["executed", "completed", "completed", "付款与回单已留存"],
["failed", "completed", "exception", "所在付款批次执行失败,待财务重新处理"],
["cancelled", "exception", "pending", "待财务重新创建付款批次"],
["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"],
["reviewed", "completed", "current", "待财务完成付款并登记回单,非审批环节"],
["executed", "completed", "completed", "付款和回单已留存"],
["failed", "completed", "exception", "付款失败,待财务重新处理,非审批环节"],
["cancelled", "exception", "pending", "待财务重新安排付款,非审批环节"],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = {
id: "batch-item-1",
@@ -670,7 +670,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
});
expect(response.statusCode).toBe(200);
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
detail: "所在付款批次待非制单人复核",
detail: "待非制单人复核付款",
actorName: null,
targetHref: "/payment-batches?id=batch-1",
});
@@ -691,8 +691,8 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
expect(response.json().data.processingProgress.stages[3]).toMatchObject({
state: "current",
actorName: null,
result: "待建批次",
detail: "待财务创建付款批次",
result: "待安排",
detail: "待财务安排付款",
});
await app.close();
@@ -0,0 +1,107 @@
const { execFile } = require("node:child_process");
const { mkdtemp, mkdir, rm, symlink, writeFile } = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const ROOT_DIR = path.resolve(__dirname, "../../../..");
const PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/preflight-remote.sh");
describe("deployment safety contract", () => {
let fixtureRoot;
let remoteDir;
let envFile;
let postgresDir;
let storageDir;
beforeEach(async () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-deploy-safety-"));
remoteDir = path.join(fixtureRoot, "app");
envFile = path.join(remoteDir, "deploy/trialrun/.env");
postgresDir = path.join(fixtureRoot, "postgres");
storageDir = path.join(fixtureRoot, "storage");
await mkdir(path.join(remoteDir, "server"), { recursive: true });
await mkdir(path.join(remoteDir, "deploy/trialrun"), { recursive: true });
await mkdir(postgresDir, { recursive: true });
await mkdir(storageDir, { recursive: true });
await writeFile(path.join(remoteDir, "server/package.json"), "{}\n");
await writeFile(path.join(remoteDir, "deploy/trialrun/docker-compose.yml"), "services: {}\n");
await writeFile(envFile, "APP_ORIGIN=https://example.invalid\n", { mode: 0o600 });
await writeFile(path.join(postgresDir, "PG_VERSION"), "16\n");
});
afterEach(async () => {
await rm(fixtureRoot, { recursive: true, force: true });
});
function preflight(overrides = {}) {
return execFileAsync("bash", [PREFLIGHT], {
env: {
...process.env,
REMOTE_DIR: remoteDir,
REMOTE_ENV_FILE: envFile,
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
POSTGRES_DATA_DIR: postgresDir,
STORAGE_HOST_DIR: storageDir,
DEPLOY_DATA_MODE: "existing",
SYNC_ENV: "0",
...overrides,
},
});
}
it("accepts a complete existing deployment contract", async () => {
await expect(preflight()).resolves.toMatchObject({ stdout: expect.stringContaining("[remote-preflight][PASS]") });
});
it("rejects a config directory masquerading as the repository root", async () => {
await expect(preflight({ REMOTE_DIR: path.join(remoteDir, "deploy/trialrun") })).rejects.toMatchObject({ code: 1 });
});
it("rejects a missing or symbolic remote env before synchronization", async () => {
await rm(envFile);
await expect(preflight()).rejects.toMatchObject({ code: 1 });
await symlink(path.join(remoteDir, "missing.env"), envFile);
await expect(preflight()).rejects.toMatchObject({ code: 1 });
});
it("rejects invalid existing data directories", async () => {
await rm(path.join(postgresDir, "PG_VERSION"));
await expect(preflight()).rejects.toMatchObject({ code: 1 });
});
it("runs remote preflight before rsync and reads existing contracts from systemd", async () => {
const deploy = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy.sh"), "utf8");
const wrapper = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8");
expect(deploy.indexOf("REMOTE_PREFLIGHT_SCRIPT")).toBeGreaterThanOrEqual(0);
expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("rsync -az --delete"));
expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("mkdir -p '${REMOTE_DIR}'"));
expect(wrapper).toContain("systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value");
expect(wrapper).not.toContain("docker inspect");
expect(wrapper).not.toContain("working_dir");
expect(wrapper).toContain("DEPLOY_DATA_MODE=existing");
expect(wrapper).toContain("SYNC_ENV=0");
});
it("keeps immutable build args after stable dependency layers", async () => {
const dockerfile = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/Dockerfile"), "utf8");
expect(dockerfile.indexOf("RUN apk add --no-cache bash curl")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA"));
expect(dockerfile.indexOf("RUN cd client && npm ci")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA"));
expect(dockerfile.indexOf("RUN cd server && npm ci")).toBeLessThan(dockerfile.indexOf("ARG BUILD_SHA"));
});
it("keeps deployment smoke output free of raw account and external identifiers", async () => {
const seed = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/seed-trialrun.js"), "utf8");
const external = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-external-deps.js"), "utf8");
const runtime = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-trialrun-runtime.js"), "utf8");
expect(seed).not.toContain("${adminEmail}");
expect(seed).not.toContain("${adminPhone}");
expect(seed).not.toContain("${result.password}");
expect(seed).not.toContain("generateOneTimePassword");
expect(external).not.toContain("corpId=${config.wecomCorpId}");
expect(external).not.toContain("redirectUri=${config.wecomRedirectUri}");
expect(external).not.toContain("from=${config.emailFrom}");
expect(runtime).not.toContain("${adminEmployee.employee_no}");
});
});
@@ -0,0 +1,106 @@
const { execFile } = require("node:child_process");
const { mkdtemp, readFile, rm, writeFile } = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { promisify } = require("node:util");
const execFileAsync = promisify(execFile);
const ROOT_DIR = path.resolve(__dirname, "../../../..");
const VALIDATOR = path.join(ROOT_DIR, "deploy/trialrun/validate-env-contract.js");
const CLASSIFICATION = path.join(
ROOT_DIR,
"deploy/trialrun/env-recovery-classification.json",
);
describe("environment recovery classification contract", () => {
let fixtureRoot;
let baseClassification;
beforeAll(async () => {
baseClassification = JSON.parse(await readFile(CLASSIFICATION, "utf8"));
});
beforeEach(async () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-env-recovery-"));
});
afterEach(async () => {
await rm(fixtureRoot, { recursive: true, force: true });
});
async function validate(classification = baseClassification) {
const fixturePath = path.join(fixtureRoot, "classification.json");
await writeFile(fixturePath, `${JSON.stringify(classification, null, 2)}\n`);
return execFileAsync("node", [VALIDATOR], {
cwd: ROOT_DIR,
env: {
...process.env,
ENV_CONTRACT_RECOVERY_CLASSIFICATION_PATH: fixturePath,
},
});
}
it("classifies every env contract key exactly once", async () => {
const result = await validate();
expect(result.stdout).toContain("环境恢复分类键数: 164");
});
it("rejects a missing key", async () => {
const classification = structuredClone(baseClassification);
classification.categories.template_rebuild = classification.categories.template_rebuild
.filter((key) => key !== "APP_TIMEZONE");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("环境恢复分类缺少 APP_TIMEZONE"),
});
});
it("rejects duplicate classification", async () => {
const classification = structuredClone(baseClassification);
classification.categories.direct_rotate.push("APP_TIMEZONE");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("环境恢复键 APP_TIMEZONE 重复分类"),
});
});
it("rejects keys outside the env contract", async () => {
const classification = structuredClone(baseClassification);
classification.categories.template_rebuild.push("SYNTHETIC_EXTRA_KEY");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("环境恢复分类包含 .env.example 外键 SYNTHETIC_EXTRA_KEY"),
});
});
it("rejects unknown categories", async () => {
const classification = structuredClone(baseClassification);
classification.categories.unreviewed = [];
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("环境恢复分类清单存在未知类别 unreviewed"),
});
});
it("rejects illegal key names and suspected values", async () => {
const classification = structuredClone(baseClassification);
classification.categories.template_rebuild.push("JWT_SECRET=not-a-key-name");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("含非法键名或疑似值"),
});
});
it("keeps FIELD_ENCRYPTION_KEY in preserve_original", async () => {
const classification = structuredClone(baseClassification);
classification.categories.preserve_original = [];
classification.categories.direct_rotate.push("FIELD_ENCRYPTION_KEY");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("FIELD_ENCRYPTION_KEY 必须归入 preserve_original"),
});
});
it("keeps POSTGRES_PASSWORD in downtime_reset", async () => {
const classification = structuredClone(baseClassification);
classification.categories.downtime_reset = [];
classification.categories.direct_rotate.push("POSTGRES_PASSWORD");
await expect(validate(classification)).rejects.toMatchObject({
stderr: expect.stringContaining("POSTGRES_PASSWORD 必须归入 downtime_reset"),
});
});
});