比较提交

83 次代码提交
作者 SHA1 备注 提交日期
wangsli aa6b9bc373 完善报销通知与付款进度(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (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
wangsli 5dac336fbf 修复移动验收门禁漂移(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 16:28:14 +08:00
wangsli 3bfcbbfc91 收口报销详情返回与金额展示(07-27-yang-jun-reimbursement-readiness) 2026-08-01 15:03:48 +08:00
wangsli 85fad71070 记录企微报销详情真机验收(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 14:10:16 +08:00
wangsli ab4b22da91 记录动态审批进度生产发布(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 14:06:33 +08:00
wangsli 7bd80ef4f4 记录动态审批节点验证(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 13:38:05 +08:00
wangsli f33b3569c6 修复报销进度审批节点覆盖(07-27-yang-jun-reimbursement-readiness) 2026-08-01 13:38:05 +08:00
wangsli 07ac5415c6 记录四块式报销详情验证(07-27-yang-jun-reimbursement-readiness) 2026-08-01 12:38:04 +08:00
wangsli 421c2d6e1e 收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness) 2026-08-01 12:37:11 +08:00
wangsli 4cf73ce0d2 补充报销事实修正审计规约(07-27-yang-jun-reimbursement-readiness) 2026-08-01 11:05:31 +08:00
wangsli de2edd660d 记录首笔差旅概要受控补录(07-27-yang-jun-reimbursement-readiness) 2026-08-01 10:54:45 +08:00
wangsli 6f60abf10f 记录合并预览生产发布(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 09:40:35 +08:00
wangsli da35591bd5 实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 09:25:44 +08:00
wangsli 3d9ec4c5a7 记录整单材料预览生产发布(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 01:13:42 +08:00
wangsli 8a9279efce 实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-08-01 00:17:54 +08:00
wangsli b6485a278e 记录报销材料交互生产发布(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 22:24:25 +08:00
wangsli 0e44aa72f3 收敛报销明细与材料交互(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 22:01:25 +08:00
wangsli 665dcabdc7 记录移动报销范围生产发布(07-27-yang-jun-reimbursement-readiness) 2026-07-31 21:23:09 +08:00
wangsli 8b376f4135 增加移动报销查看范围(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 20:43:33 +08:00
wangsli 20b20b100e 记录企微原生预览生产验收(07-27-yang-jun-reimbursement-readiness) 2026-07-31 19:44:02 +08:00
wangsli 410d8cd71b 实现企微原生材料预览(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 18:57:34 +08:00
wangsli e123d50f86 增加企微旧包刷新护栏(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 17:40:25 +08:00
wangsli 39c3e808a3 修复报销详情时区并按 KISS 收敛页面(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 16:44:31 +08:00
wangsli 35b44749a5 记录 07-27-yang-jun-reimbursement-readiness 生产发布验收
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 15:24:00 +08:00
wangsli d2841b166d 修复审批详情报销入口(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-31 10:05:35 +08:00
wangsli dfbbe9a750 归档 PC 登录页桌面布局任务(07-30-pc-login-desktop-layout) 2026-07-31 08:30:25 +08:00
wangsli de65a07a51 优化 PC 登录页桌面布局(07-30-pc-login-desktop-layout) 2026-07-31 08:29:30 +08:00
wangsli ceeb0b5a2f 归档 PC 企微扫码任务(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-30 18:39:04 +08:00
wangsli aea28ed3be 记录 PC 企微扫码生产验收(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-30 18:38:47 +08:00
wangsli eb3ab3455d 记录 PC 企微扫码关闭态生产部署(07-30-pc-wecom-qr-login) 2026-07-30 18:01:57 +08:00
wangsli fb164eb690 增加 PC 企业微信扫码登录(07-30-pc-wecom-qr-login)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-30 17:16:22 +08:00
wangsli 45dc206a4c 记录报销附件修复生产部署(07-28-travel-reimbursement-guide) 2026-07-30 16:10:48 +08:00
wangsli 030cf46c92 完善附件数量上限提示(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-30 14:58:33 +08:00
wangsli 734153847b 修复报销附件上传与中文文件名(07-28-travel-reimbursement-guide) 2026-07-30 14:41:08 +08:00
wangsli 531d2846a6 记录差旅补助生产部署验收(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 21:53:07 +08:00
wangsli 13a3f4b57d 完善差旅补助规定与费用类型(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 20:04:35 +08:00
wangsli 5df5f12ad6 记录OCR双样票验收(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 16:03:08 +08:00
wangsli 665cf4add5 修复铁路OCR空金额回退(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 15:32:16 +08:00
wangsli 5f2788ccac 增加铁路OCR金额字段安全诊断(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 15:07:58 +08:00
wangsli 26562255a0 记录live OCR样票捕获约束(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 14:24:06 +08:00
wangsli 33edcd214e 记录live OCR验收工具生产部署(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 14:08:03 +08:00
wangsli fa326f92fa 收敛真实OCR样票验收入口(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 08:28:42 +08:00
wangsli 6a1fd4d789 记录OCR生产部署验收(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-29 07:48:42 +08:00
wangsli 1154887aab 记录OCR生产配置与部署根收敛方案(07-28-travel-reimbursement-guide)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-28 22:39:43 +08:00
wangsli ac17092fa0 实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide) 2026-07-28 22:02:11 +08:00
wangsli b4bd484260 归档Claude生产复核任务(07-28-claude-production-review-verification) 2026-07-28 15:35:51 +08:00
wangsli ac0abafc0e 记录Claude生产复核结论(07-28-claude-production-review-verification) 2026-07-28 15:35:35 +08:00
wangsli eaa2dc0ff3 归档CEO财务兜底任务(07-28-ceo-finance-fallback)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-28 15:01:18 +08:00
wangsli 0c779ab4f9 记录CEO财务兜底生产收口(07-28-ceo-finance-fallback) 2026-07-28 15:00:35 +08:00
wangsli a74cba1112 修复生产回填审计标识(07-28-ceo-finance-fallback)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-28 14:25:03 +08:00
wangsli 35948729cf 修正CEO报销财务兜底与部署护栏(07-28-ceo-finance-fallback)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-28 13:42:48 +08:00
wangsli 37b625c5dc 修正业务职责与主管关系事实(07-28-all-account-login-readiness) 2026-07-28 12:57:40 +08:00
wangsli dda18f53d1 记录全员账号与主管关系核对(07-28-all-account-login-readiness) 2026-07-28 12:43:49 +08:00
wangsli 9b12451b85 实现企微首次免密自动绑定(07-28-all-account-login-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s
2026-07-28 11:18:50 +08:00
wangsli 0551212b78 撤销提交个人 Trellis 日志(07-28-automatic-backup-runtime-identity-failure) 2026-07-28 07:55:28 +08:00
wangsli 0d58ff138b 记录自动备份修复会话(07-28-automatic-backup-runtime-identity-failure) 2026-07-28 07:55:20 +08:00
wangsli b0037dd907 归档自动备份运行身份修复任务(07-28-automatic-backup-runtime-identity-failure) 2026-07-28 07:55:06 +08:00
wangsli cba74cf97d 修复自动备份运行身份漂移(07-28-automatic-backup-runtime-identity-failure) 2026-07-28 07:54:37 +08:00
wangsli 4e3c6c4376 归档 ARM 应用健康监控上线任务(07-27-arm-app-health-rollout) 2026-07-27 23:20:46 +08:00
wangsli f908d2a46d 完成 ARM 应用健康监控上线(07-27-arm-app-health-rollout) 2026-07-27 23:20:26 +08:00
wangsli 4b76bd0afd 归档应用健康监控任务(07-27-app-health-monitoring) 2026-07-27 22:30:59 +08:00
wangsli bb5113c58b 实现应用健康监控(07-27-app-health-monitoring) 2026-07-27 22:30:33 +08:00
wangsli 99a6c20981 收敛小团队报销审批规则(07-27-yang-jun-reimbursement-readiness) 2026-07-27 21:32:47 +08:00
wangsli 4147a6960f 记录真实报销首跑风险(07-27-yang-jun-reimbursement-readiness) 2026-07-27 21:23:15 +08:00
wangsli 9694179c25 验证OCR关闭后的报销闭环(07-27-yang-jun-reimbursement-readiness) 2026-07-27 20:54:19 +08:00
wangsli 68600ec2d5 完成杨俊报销就绪配置与企微边界记录(07-27-yang-jun-reimbursement-readiness) 2026-07-27 19:39:53 +08:00
wangsli 0c00bf119e 归档内部OA运行恢复任务(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 18:47:34 +08:00
wangsli 0783cf833a 记录企微职责生产验收(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 18:47:00 +08:00
wangsli 768abb3679 记录企微通讯录生产复验(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 18:40:51 +08:00
wangsli 4886f9b081 修复企微通讯录生产导入兼容性(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 18:31:04 +08:00
wangsli cf63d3cb1b 归档异机备份最终收尾任务(07-27-offhost-backup-final-fixes)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 18:08:44 +08:00
wangsli 21c49dd941 记录异机备份收尾远端验证(07-27-offhost-backup-final-fixes) 2026-07-27 18:08:30 +08:00
wangsli 01f1259557 修复异机备份凭证与中断恢复(07-27-offhost-backup-final-fixes)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 17:58:11 +08:00
wangsli f1e8cf138a 归档每日自动异机备份任务(07-27-automatic-offhost-backup) 2026-07-27 17:32:52 +08:00
wangsli 95b51e6245 调整单管理员恢复运维口径(07-27-automatic-offhost-backup) 2026-07-27 17:32:07 +08:00
wangsli de35ab8cf6 记录异机备份远端启用与告警验收(07-27-automatic-offhost-backup) 2026-07-27 17:25:25 +08:00
wangsli a0006f07b8 完善异机备份告警与同日恢复点取代(07-27-automatic-offhost-backup)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 13:54:16 +08:00
wangsli 2526052e73 支持企微通讯录缺失手机号(07-26-internal-ops-recovery-baseline) 2026-07-27 13:05:27 +08:00
wangsli 9875bc37a4 修复自动备份真实挂载解析(07-27-automatic-offhost-backup)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 11:19:13 +08:00
wangsli b78f2a11ce 建立每日自动异机备份基线(07-27-automatic-offhost-backup) 2026-07-27 10:50:01 +08:00
wangsli 325c592aba 加固生产运维定时器绑定(07-26-internal-ops-recovery-baseline)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run
2026-07-27 07:59:48 +08:00
共修改 273 个文件,包含 21565 行新增和 1914 行删除
+3
查看文件
@@ -24,6 +24,9 @@ client/playwright-report
output/
artifacts/
# Local sensitive business samples; never commit originals
/input/
# Vitest coverage artifacts
server/coverage
server/.vitest
+223
查看文件
@@ -93,3 +93,226 @@ if (!hasPermission(actor, "payment.batch.review")
throw forbidden();
}
```
## Scenario: Reimbursement End-To-End Processing Projection
### 1. Scope / Trigger
- Trigger: showing one reimbursement's progress across approval workflow,
payment-batch review, payment execution and receipt retention.
- This is a read-only projection for diagnosis. It must not merge the workflow
and payment state machines or create synthetic workflow tasks.
### 2. Signatures
- Detail API: `GET /api/reimbursements/:id`.
- Response: `processingProgress.stages[]` with `key`, `label`, `state`,
nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and
permission-gated `targetHref`.
- Fixed stage keys: `submitted`, `payment_batch_review` and
`payment_and_receipt`. Each persisted workflow task contributes one unique
approval-stage key derived from task identity; clients treat keys as opaque
strings and never infer responsibility from their value.
### 3. Contracts
- Always return submission first, then one stage per real workflow task in the
query's stable `created_at,id` order. Because the engine creates only the
current task, append not-yet-materialized future steps from the immutable
`workflow_steps_snapshot` after the current step, then return payment-batch
review and payment/receipt. The current two-approval production rule therefore
returns five stages from initial submission onward; configured extra or
repeated-role approvals increase the array length instead of being discarded,
merged or temporarily hidden.
- Never collect workflow tasks into a `Map` keyed by semantic responsibility:
two finance, manager or CEO tasks are distinct historical facts and later
tasks must not overwrite earlier ones. The UI renders `stages` with a plain
ordered `map` and contains no fixed-length or positional responsibility logic.
- Approval labels use a readable non-generic `step_name` first. Empty,
ordinal (`步骤2`, `审批节点1`, `step 3`), raw technical identifier or generic
(`审批`, `审核`, `复核`) names fall back through role mapping
(`manager -> 主管审批`, `finance|finance_mgr -> 财务复核`,
`ceo -> 负责人复核`) and finally `审批`. UI labels never expose step IDs,
role codes or sequence numbers.
- Completed stages use the actual completer/actor and completion time. Current
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
`待非制单人复核付款`; 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
payment-batch read permissions. The projection never returns batch peers,
aggregate amount, other materials or bank receipt contents.
### 4. Validation & Error Matrix
- No workflow instance -> submission/applicant state remains readable and the
approval segment falls back to pending `主管审批` / `财务复核` placeholders.
- Workflow instance with no task yet -> build pending approval stages from its
immutable snapshot. Current task exists but later tasks do not -> append those
future snapshot stages with `actorName=null`; never infer future employees.
- A third unknown task -> one additional approval stage in its real position;
it must not disappear before the payment stages.
- Two tasks with the same role -> two unique ordered stages with their own
actor, state, time and comment; neither may overwrite the other.
- Workflow approved + no batch -> payment-batch stage is current, future actor
remains null.
- 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 -> `付款和回单已留存`.
- 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
fabricate a completed or pending business fact.
### 5. Good/Base/Bad Cases
- 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
future names.
- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip
the third task, display an expected checker before batch creation, label a
shared batch as `您的报销待复核`, or hide the whole timeline because no receipt
exists.
### 6. Tests Required
- Route tests for no workflow, empty-task instance, a current task plus future
snapshot-only steps, pending/completed/returned/rejected tasks, a third
unclassified task, two same-role tasks, all five batch statuses, receipt count
zero/positive and paid-without-batch legacy data.
- Assert future stages have null actors and ordinary applicants receive no
batch link or batch-adjacent business data.
- PC/mobile tests assert variable-length ordered rendering, no `第 N 步` or raw
ordinal label, comments on exception nodes, four-block order and no loss of
earlier stages when a downstream source is absent.
### 7. Wrong vs Correct
#### Wrong
```js
for (const task of workflowTasks) {
stageByRole.set(classify(task), buildStage(task));
}
```
#### Correct
```js
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)] });
```
@@ -23,6 +23,58 @@ QA script patterns over new abstractions.
- `cd server && ./scripts/regression-workflow-actions.sh`
- `cd server && ./scripts/regression-all.sh`
## Scenario: Prisma Client Defaults In Raw SQL
### 1. Scope / Trigger
- Trigger: a script or route uses `$queryRaw`, `pg` or handwritten `INSERT` SQL against a Prisma-managed table whose schema declares `@default(uuid())` or another client-side default.
### 2. Signatures
- Prisma signature: `id String @id @default(uuid()) @db.Uuid`.
- Database signature may still be `id UUID NOT NULL` without a database `DEFAULT`.
- Raw insert must include `id` and bind an explicit UUID, for example `$1::uuid`.
### 3. Contracts
- Prisma schema defaults are not assumed to exist in PostgreSQL; inspect the applied migration or live column default before omitting a raw-SQL field.
- Raw audit inserts generate a fresh UUID in application code and keep the audit insert in the same transaction as the protected write.
- Dry-run may exercise audit inserts, but it must roll the transaction back and report zero committed audit rows.
### 4. Validation & Error Matrix
- Raw insert omits a required client-default field with no DB default -> PostgreSQL not-null failure; transaction rolls back.
- Explicit UUID has invalid syntax -> PostgreSQL cast failure; transaction rolls back.
- Audit insert fails after the business update -> the complete transaction rolls back; no partial rule update is allowed.
### 5. Good/Base/Bad Cases
- Good: explicit UUID, request ID, actor, target and JSON audit payload are bound and tested position by position.
- Base: Prisma Client `create()` may rely on the Prisma default because the client supplies it.
- Bad: infer from `@default(uuid())` that handwritten PostgreSQL may omit `id`.
### 6. Tests Required
- Assert the raw SQL column list contains the required ID and the bound value is a valid UUID.
- Assert every parameter position maps to its intended DB type.
- Assert dry-run begins a serializable transaction and executes `ROLLBACK`; audit failure paths must remain fail-closed.
### 7. Wrong vs Correct
#### Wrong
```sql
INSERT INTO audit_logs (request_id, actor_id, extra)
VALUES ($1, $2::uuid, $3::jsonb);
```
#### Correct
```sql
INSERT INTO audit_logs (id, request_id, actor_id, extra)
VALUES ($1::uuid, $2, $3::uuid, $4::jsonb);
```
## Scenario: Local Regression Migration Drift
### 1. Scope / Trigger
+542 -5
查看文件
@@ -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
@@ -608,9 +608,10 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
### 1. Scope / Trigger
- Trigger: changing Compose identity, host data mounts, database publication,
deployment entrypoints or operational timers.
deployment entrypoints, deployment-time seeds or operational timers.
- Purpose: ensure one approved Compose project owns one verified DB/storage pair
without fixed global container names or accidental shared PGDATA.
without fixed global container names, source synchronization touching runtime
data, or deployment seeds overwriting approved production rules.
### 2. Signatures
@@ -618,7 +619,13 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
- PostgreSQL mount: `${POSTGRES_DATA_DIR:-./data/postgres}`.
- Application storage mount: `${STORAGE_HOST_DIR:-./data/storage}`.
- Formal deploy inputs: absolute `POSTGRES_DATA_DIR`, absolute
`STORAGE_HOST_DIR`, `DEPLOY_DATA_MODE=existing|initialize`.
`STORAGE_HOST_DIR`, absolute `REMOTE_DIR`, absolute `REMOTE_ENV_FILE`,
`DEPLOY_DATA_MODE=existing|initialize`.
- Deployment init includes `npm run seed:config`; approval-rule defaults are
inserted only when `approval_rules` is empty.
- Timer install inputs: `COMPOSE_PROJECT_NAME`, absolute
`POSTGRES_DATA_DIR`, absolute `STORAGE_HOST_DIR`, full 40–64 hex
`BUILD_SHA` and its exact 12-hex `VITE_BUILD_SHA` prefix.
- DB has no host `ports`; server remains loopback-only.
### 3. Contracts
@@ -634,11 +641,30 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
- Relative defaults preserve local trialrun compatibility, but production must
use explicit absolute command inputs and verify them against active container
mounts before any start, backup or migration.
- `REMOTE_DIR` must not equal, contain or be contained by either production
data path. Validate this locally before provenance, SSH or `rsync --delete`.
If `REMOTE_ENV_FILE` is inside the synchronization tree, derive its actual
root-relative path and add explicit rsync protect/exclude filters; a fixed
default `.env` exclusion is insufficient.
- Deployment-time `seed:config` may create the three default approval rules for
an empty database. If any approval rule exists, preserve all existing rules;
production rule changes require their own approved transaction, drift checks
and audit rows.
- `existing` is the normal migration mode and refuses a missing PGDATA marker
or storage directory. `initialize` is only for an approved empty first start;
only that mode may create data directories.
- Remote readiness may use host curl, but build-SHA JSON validation runs with
Node inside the server container; the Docker host does not need Node.
- The timer installer must validate the same non-root, non-overlapping absolute
data-path shape as formal deploy, require an existing `PG_VERSION` and
storage directory, and write both paths plus both build identifiers into the
systemd service `Environment=` entries. Project name alone is insufficient:
a later timer invocation would otherwise interpolate Compose defaults and
operate against a non-active data pair.
- Timer installation may enable/start the timer unit, but verification must not
manually start the daily service because that path can send real alerts.
Verify the unit environment against active container mounts, then run
Compose `config --quiet` and `exec -T server true` with the same values.
- For an existing cutover, stop the old PostgreSQL completely, copy PGDATA and
storage into pre-verified empty absolute targets while preserving ownership,
permissions, links and extended attributes, and retain the old directories
@@ -651,8 +677,21 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
- Any fixed `container_name` appears -> environment contract fails.
- Required data mount loses its parameter -> environment contract fails.
- Deploy/timer omits `-p` -> environment contract fails.
- Timer install omits either data path/build identifier, receives nested paths,
a PGDATA without `PG_VERSION`, a missing storage directory, an invalid full
SHA or a 12-character value that is not its prefix -> fail before writing the
systemd unit.
- A path-segment regex can match an empty string at the leading `/` -> contract
failure; use explicit root, trailing slash, `//`, `/./` and `/../` checks so
valid absolute paths remain installable.
- Formal deploy omits paths/mode, uses a relative/broad/overlapping path, or an
`existing` PGDATA lacks `PG_VERSION` -> fail before DB start.
- `REMOTE_DIR` overlaps PostgreSQL or attachment storage in either direction ->
fail before provenance, SSH and rsync.
- A custom `REMOTE_ENV_FILE` lies inside the sync tree without dynamic
protect/exclude filters -> deployment contract fails.
- `approval_rules` contains one or more rows -> `seed:config` reports them as
preserved and performs no approval-rule create, update or delete.
- New project resolves the same PGDATA while the old DB runs -> stop before
Compose start; do not use service health as permission to continue.
- Old project name was inferred, immutable image ID or baseline counts are
@@ -662,19 +701,37 @@ curl -fsS "http://127.0.0.1:${SERVER_HOST_PORT}/ready"
- `node deploy/trialrun/validate-env-contract.js`.
- The isolated backup/restore drill mutates Compose with a DB port and fixed
name and proves the contract rejects it.
name, invokes formal deploy with each data path nested below `REMOTE_DIR`, and
proves every unsafe shape is rejected before network access.
- `bash -n` covers deploy, timer, backup and restore scripts.
- Seed unit tests assert: an empty table receives exactly three defaults; any
non-empty table receives zero approval-rule writes and no delete call.
- Timer contract assertions cover all four explicit inputs, path isolation,
PGDATA marker, SHA/prefix validation and the four systemd `Environment=`
entries. On the deployment host, assert timer enabled/active, daily service
inactive, exact active-environment equality and non-mutating Compose resolve.
### 6. Good/Base/Bad Cases
- Good: one approved project resolves one stopped-and-migrated PGDATA and the
matching active storage path; deploy, timer and backup all use that identity.
- Good: source lives under one absolute root, data lives under separate absolute
roots, a custom env file is protected, and existing approval rules survive
deployment init byte-for-byte until an approved rule transaction runs.
- Good: the timer unit contains the current project, active mount sources and
deployed full/short SHA pair; Compose config and a non-mutating server exec
resolve under those exact values while the daily service stays inactive.
- Base: direct local Compose uses the documented relative defaults under one
project; those defaults are never accepted by formal `deploy.sh`.
- Bad: start a renamed project before the old DB stops, or let it create a new
empty directory and accept a green health check as proof of data continuity.
- Bad: move the only PGDATA into the new path, migrate it in place, then assume
the old container name alone is a rollback point.
- Bad: reinstall the timer with only `COMPOSE_PROJECT_NAME`, allowing systemd
Compose interpolation to fall back to repository-relative data directories.
- Bad: place PGDATA or attachments below `REMOTE_DIR` and rely on one hard-coded
rsync exclusion, or delete/recreate configurable approval rules in a routine
deployment seed.
### 7. Wrong vs Correct
@@ -697,6 +754,39 @@ DB_TOOL_MODE=docker DB_CONTAINER="${DB_CONTAINERS[0]}" \
STORAGE_DIR_HOST=<verified-active-storage> ./scripts/backup-all.sh
```
For timer installation, keep the active deployment identity explicit:
```bash
# Wrong: project identity alone still permits relative data-path fallback.
COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT_NAME" \
bash deploy/trialrun/install-warch3-ops-timer.sh install
# Correct: use the verified active mounts and deployed SHA pair.
COMPOSE_PROJECT_NAME="$COMPOSE_PROJECT_NAME" \
POSTGRES_DATA_DIR=<verified-active-pgdata> \
STORAGE_HOST_DIR=<verified-active-storage> \
BUILD_SHA=<verified-full-build-sha> \
VITE_BUILD_SHA=<verified-12-char-prefix> \
bash deploy/trialrun/install-warch3-ops-timer.sh install
```
For source synchronization and approval-rule defaults:
```bash
# Wrong: runtime data sits below the --delete synchronization root, and seed
# replaces operator-managed rules during every deployment.
REMOTE_DIR=/srv/oa POSTGRES_DATA_DIR=/srv/oa/data/postgres \
bash deploy/trialrun/deploy.sh
# seedApprovalRules: deleteMany -> createMany
# Correct: data roots are disjoint; an in-tree env file is dynamically
# protected; seed creates defaults only for an empty approval_rules table.
REMOTE_DIR=/srv/oa-app POSTGRES_DATA_DIR=/srv/oa-data/postgres \
STORAGE_HOST_DIR=/srv/oa-data/storage DEPLOY_DATA_MODE=existing \
bash deploy/trialrun/deploy.sh
# seedApprovalRules: count > 0 -> preserve; count = 0 -> create defaults
```
## Scenario: Paired Backup And Shadow-Only Restore
### 1. Scope / Trigger
@@ -867,3 +957,450 @@ SQL
test "$(printf '%s\n' "$actual" | LC_ALL=C sort)" = \
"$(printf '%s\n' "$expected" | LC_ALL=C sort)"
```
## Scenario: Daily Restricted Off-Host Backup
### 1. Scope / Trigger
- Trigger: installing or changing automatic paired backups, off-host transfer,
retention, freshness alerts or quarterly restore reminders.
- Purpose: keep a 24-hour recovery point without giving the production host
ordinary shell or delete access to the ARM archive.
### 2. Signatures
- Production runner: `scripts/run-automatic-backup.sh` with `run`,
`check-stale` and `remind-drill` modes.
- ARM forced receiver: `scripts/receive-offhost-backup.sh`.
- Shared host alert helper: `scripts/send-wecom-group-alert.py` with explicit
`--webhook-file`, `--summary-file` and `--state-file` paths.
- ARM watchdog: `scripts/check-offhost-backup-deadman.py` with archive root,
state root, alert helper, Webhook file and a default `93600` second threshold.
- Installers: `install-automatic-backup-timer.sh` and
`install-offhost-backup-receiver.sh`.
- Local contract: `bash scripts/qa/run-automatic-backup-contract.sh`.
- Production required environment: `REMOTE_DIR`, `REMOTE_ENV_FILE`,
`COMPOSE_PROJECT_NAME`, `POSTGRES_DATA_DIR`, `STORAGE_HOST_DIR`, `BUILD_SHA`,
`VITE_BUILD_SHA`, `AUTOMATIC_BACKUP_DIR`, `AUTOMATIC_BACKUP_STATE_DIR`,
`OFFHOST_TARGET`, `OFFHOST_SSH_KEY`, `OFFHOST_KNOWN_HOSTS`,
`ALERT_WEBHOOK_FILE` and `ALERT_HELPER`.
- Receiver config: independent absolute `BACKUP_INBOX_DIR`,
`BACKUP_ARCHIVE_DIR`, `BACKUP_WEEKLY_DIR`, `RRSYNC_BIN`, `PYTHON_BIN`,
`DAILY_KEEP` and `WEEKLY_KEEP`.
### 3. Contracts
- Resolve one active Compose server/database pair and verify its build, PGDATA,
storage mount and database identity before backup. Do not use fixed container
names or repository-relative runtime data.
- Parse Docker mount identity from `docker inspect` JSON and require exactly one
destination match. Do not split human-formatted Go-template output on tabs or
spaces: `println` inserts argument spacing that can make a visually correct
line fail exact path comparison on a real daemon.
- Database connection values used by the automatic wrapper must be exported in
an isolated child shell before `capture_command`; do not invoke `env` with a
complete `DATABASE_URL` or password assignment in its argv. Docker backup and
shadow-restore commands inherit `PGPASSWORD` with
`PGPASSWORD="$POSTGRES_PASSWORD" docker exec -e PGPASSWORD ...`; never use a
value-bearing `-e PGPASSWORD=...` Docker argument.
- Transfer only a complete `auto-<UTC timestamp>` three-file batch to ARM
inbox. The forced key allows write-only `rrsync` or an exact finalize command;
it rejects shell, sender/read and destructive rsync options.
- ARM revalidates manifest identity, exact regular-file set, sizes and SHA-256,
then atomically moves the batch to archive. Archive and weekly directories are
outside the rsync root. A different run ID for the same UTC day may replace
that day's archive only after full candidate validation: rename the old daily
point to a same-filesystem rollback name, publish the candidate, rebuild that
ISO week's hard-link checkpoint, then remove the rollback. Any publication or
weekly-checkpoint failure restores the prior daily point.
- After taking the finalize lock and before reading the incoming partial, ARM
reconciles interrupted same-filesystem transactions. A valid
`.daily-replacement-<UTC-day>.*` is deleted only when one valid canonical
archive for that day exists; when the canonical is absent it is renamed back
from its own verified manifest identity. Invalid, linked, duplicate or
otherwise ambiguous daily state fails closed and preserves every object.
- Weekly `.rollback.*` is authoritative only after full batch validation and is
restored when the canonical checkpoint is absent. Weekly `.new.*` is never an
authoritative source; it may be removed only after a valid canonical,
rollback or unique valid daily source is established. Invalid canonical,
rollback, duplicate candidate, week mismatch or non-unique rebuild source
fails closed without deleting evidence. A reconciled daily point forces its
ISO-week checkpoint to be rebuilt before finalize continues. When a valid
canonical daily point and its old rollback coexist, defer deleting that daily
rollback until every required weekly reconciliation has succeeded; a weekly
validation or rebuild failure must leave the daily rollback in place.
- Write production `last-success.json` and prune source copies only after ARM
finalize succeeds. Keep seven automatic source/ARM daily points and four ARM
weekly checkpoints; unrelated manual batches are outside automatic retention.
- Daily, freshness and quarterly reminder timers are separate. Alert summaries
contain categories only. A shared Python helper reads the complete WeCom group
robot URL only from a regular, executing-account-owned `0600` file and never
prints it. It sends the first failure, worsening/new findings, one recovery
notice and the first quarterly reminder, while suppressing an identical
repeated state. It does not call the OA server container or resolve an employee.
- ARM runs its own 26-hour dead-man as root. It verifies the newest archive's
exact file set, manifest identity, sizes, checksums and timestamp, so a stopped
production host/server can still cause a group alert. Missing, corrupt, stale
or future-dated archives fail; recovery sends once.
- Production command diagnostics for paired backup, rsync and finalize stay only
in the state directory as `last-error.log` mode `0600`. The summary and group
message contain only the safe phase category. A later successful off-host run
removes the retained diagnostic.
- The production freshness check hard-fails missing, malformed, future, stale or
Compose-project-mismatched state. A fresh recovery point with a different
build SHA is `PASS_WITH_FINDINGS`, because deploy-time code identity and
recoverable data freshness are separate facts.
- Automatic-backup units freeze the active Compose project, build, PGDATA and
storage mount when installed. After any deployment, rollback or direct
container rebuild that changes the active container identity or host mounts,
compare all frozen values with the unique running server/database pair. If
the units are already installed and any value differs, rerun the existing
installer with the complete current contract and recheck all identities
before declaring the deployment complete; do not patch one unit in place.
- Installer `install` is disabled-by-default; `enable`, real transfer, real
WeCom alert and all server/account/key changes remain `EXTERNAL_GATED`.
### 4. Validation & Error Matrix
- Source mount/build/project drift, lock conflict or incomplete backup -> fail
before success state and source retention.
- Transfer/finalize failure, wrong/extra/link artifact or bad hash -> leave no
off-host success state, preserve the source batch and retain a root-only local
diagnostic. A corrupt same-day candidate leaves the previous archive intact.
- Valid same-day retry -> replace only that UTC day's point and rebuild its ISO
weekly checkpoint; concurrent finalize -> `flock` rejection.
- Interrupted daily replacement with valid canonical -> remove the verified old
rollback only after rebuilding the weekly point; canonical missing -> restore
the verified rollback; canonical invalid, weekly rebuild failure or candidates
ambiguous -> preserve the remaining rollback evidence and fail.
- Interrupted weekly replacement with valid canonical -> remove safe staging
and verified rollback; canonical missing + valid rollback -> restore it;
staging-only + one valid daily source -> rebuild; any invalid or ambiguous
authority -> preserve all objects and fail.
- Missing, corrupt, stale, future or project-mismatched success record -> hard
watchdog failure. Fresh build mismatch -> one finding without a failing exit.
- `phase_stale_check_failed` can also mean the watchdog failed its runtime
identity precheck before reading the success record. Diagnose the nested safe
category and active-unit identity first; do not infer that the last archive is
stale or corrupt from this outer phase alone.
- Missing/non-regular/symlink/non-`0600` Webhook file -> fail closed without
printing the file value. Identical alert state -> update/evaluate without send.
- Ordinary shell, rsync sender/read/delete or archive overwrite -> receiver
rejection without changing archive.
- ARM host/network/WeCom all unavailable at once -> the group robot path cannot
prove its own outage; this physical boundary is not a green state.
### 5. Good/Base/Bad Cases
- Good: one verified active source creates a complete batch, write-only rsync
resumes inside inbox, ARM finalize verifies and archives it, then production
writes success and prunes old automatic source points. If production later
stops, ARM independently raises one dead-man alert and one recovery notice.
- Good: a deployment rebuilds the server with a different storage mount, then
refreshes the installed backup units through the installer and proves every
frozen identity matches before closing the deployment.
- Base: local implementation and synthetic contracts pass while both server
installers remain disabled and explicitly externally gated.
- Bad: send through `compose exec server`, hard-code a personal employee number,
persist a robot URL in `.env`/unit/repository, give production a normal ARM
login key, put archive below inbox, accept a new host fingerprint automatically,
prune local points before target-side finalize, or leave installed backup
units bound to a pre-deployment mount until the next scheduled alert.
### 6. Tests Required
- Shell syntax plus `run-automatic-backup-contract.sh` using temporary files and
mocked Docker/SSH boundaries; it must never contact real hosts.
- Docker mocks must return the real `{{json .Mounts}}` shape consumed by the
runner. A simplified tab-delimited mock is not valid evidence for real mount
parsing.
- Contract assertions for same-day replacement, corrupt-candidate preservation,
weekly hard-link rebuild, daily/weekly interrupted-transaction recovery,
rollback preservation when weekly reconciliation fails, ambiguous-state
preservation, source diagnostic mode, build mismatch finding, Webhook
mode/symlink rejection and group alert behavior without a server container.
Test transport must be explicitly gated and local-only.
- Mock Docker assertions must prove synthetic connection values are absent from
wrapper/backup/restore argv while an inherited `PGPASSWORD` reaches the
mocked container command. Test-fixture `env KEY=value` launch arrays do not
need mechanical rewriting because they contain only synthetic values.
- Environment contract assertions for both helpers, both installers, production
timers, ARM dead-man timer, explicit project/data/build/SSH/Webhook-file
binding, disabled-by-default installation and absence of a literal complete
robot URL in tracked or untracked repository files.
- Deployment and rebuild review must assert that installed production units
still match the unique active project, build, PGDATA and storage mount. A
changed mount with otherwise healthy `/ready` is not a completed deployment
until installer refresh, timer state and runtime identity all pass.
- Real ARM setup, first paired batch, deliberate source-timer stop or transfer
failure, dead-man first/repeated/recovery behavior and quarterly shadow restore
are separately approved operational evidence. A direct robot send test alone
is not dead-man evidence.
### 7. Wrong vs Correct
#### Wrong
```bash
# Rebuild the application, observe /ready, but leave installed backup units
# frozen to the pre-deployment runtime identity.
docker-compose <approved-compose-arguments> up -d --build server
compose exec server send-alert --recipient=<personal-employee-number>
rsync -a <batch>/ backup-host:<archive>/
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" db pg_dump ...
```
#### Correct
```bash
# After a deployment/rebuild, compare the installed unit contract with the
# unique active server/database pair. If any frozen identity changed, refresh
# all units through the installer before verifying timers and identity again.
<complete-current-contract-env> \
bash deploy/trialrun/install-automatic-backup-timer.sh enable
# Install only with explicit active identity and a root-only Webhook file path;
# default remains disabled.
<explicit-project-data-build-ssh-and-webhook-file-env> \
bash deploy/trialrun/install-automatic-backup-timer.sh install
# ARM separately verifies archive freshness even when production is unavailable.
python3 scripts/check-offhost-backup-deadman.py <explicit-safe-path-arguments>
# The runner writes success and prunes only after restricted target finalize.
bash scripts/run-automatic-backup.sh run
# The Docker CLI inherits the value without placing it in argv.
PGPASSWORD="$POSTGRES_PASSWORD" docker exec -e PGPASSWORD db pg_dump ...
```
## Scenario: Independent Public Application Health Monitor
### 1. Scope / Trigger
- Trigger: installing or changing the off-host public `/ready` probe, its ARM
timer, application-health alert states or the shared WeCom group-alert helper.
- Purpose: detect an unavailable OA host, edge/TLS path, application or database
readiness signal without adding a time-series platform or relying on traffic.
### 2. Signatures
- Probe: `scripts/check-public-app-health.py --ready-url <https-/ready> --state-dir
<root-0700-dir> --webhook-file <root-0600-file> --alert-helper <installed-helper>
--failure-threshold 2 --timeout-seconds <1..30>`.
- Installer: `deploy/trialrun/install-offhost-app-health-monitor.sh
install|enable|uninstall`.
- Units: `hyb-oa-public-app-health.service` and
`hyb-oa-public-app-health.timer`; the timer uses `OnCalendar=*:0/5`.
- Local contract: `bash scripts/qa/run-app-health-monitor-contract.sh`.
### 3. Contracts
- Run the probe from the existing ARM host against an exact HTTPS `/ready` URL.
Success requires trusted TLS, HTTP 2xx, bounded JSON and `data.ok === true`.
- Count any stable failure category toward the same consecutive-failure
threshold. The first failure is pending; the second evaluates a failure
alert. Once alerting, a changed category may send a new alert, identical
failures are deduplicated, and one later success sends one recovery notice.
- Persist only schema version, count, stable category and the alert state in a
root-owned `0700` directory with atomic `0600` files. Never persist or send
the URL, resolved address, response payload, credential or raw exception.
- Application-health probe state and alert state are separate from backup and
dead-man state. The installer must deploy the helper revision that passed the
backup contract; an older installed helper may reject the new status tokens.
- `install` writes only the exact managed probe/helper/state/unit targets and
leaves the timer disabled. `enable`, ARM writes, real robot messages and a
controlled failure/recovery drill remain separately approved operations.
- Before any remote write, prove the selected SSH identity can run `sudo -n`
and that no application-health artifact already exists. A password prompt is
a stop condition, not permission to request or transmit a password in chat.
If the operator creates a temporary privilege rule, remove its exact file at
the end, validate sudoers in the same root invocation, invalidate the sudo
timestamp and prove the original non-interactive access is gone.
- `uninstall` removes only the exact application-health units and probe. It
retains the shared helper, webhook, state evidence and all backup components.
### 4. Validation & Error Matrix
- Non-HTTPS, userinfo, query, fragment or non-`/ready` URL -> reject before a
request with `ready_url_invalid`.
- DNS/connect, TLS, timeout, HTTP, oversized response or invalid JSON/contract
-> stable redacted category and non-zero oneshot exit.
- Invalid owner/mode/link/state or alert-helper failure -> fail closed with a
fixed local error; do not include the offending value.
- A first isolated failure followed by health -> no failure or recovery message.
- Any overridden managed target or service name -> installer stops before
`systemctl`, file replacement or removal.
- SSH succeeds but `sudo -n` requests a password -> stop before upload or
systemd mutation; resume only after an operator establishes a separately
approved, non-chat root path and rerun the complete preflight.
- Temporary privilege rule remains after final health verification -> rollout
is incomplete even when the timer is healthy.
- ARM itself or its network/robot path unavailable -> the monitor can be silent;
do not report this design as having an independent third-party dead-man.
### 5. Good/Base/Bad Cases
- Good: ARM observes two consecutive unavailable checks, sends one redacted
category alert, suppresses repeats and sends one recovery after health returns.
- Base: repository contracts pass while units remain disabled; report the code
as ready for an approved rollout, not as an active monitor.
- Bad: run the timer only on the OA host, send raw `/ready` errors, install and
enable in one unapproved step, or deploy the probe without its helper revision.
### 6. Tests Required
- The local HTTPS stub must assert healthy baseline, pending first failure,
second-failure alert, identical deduplication, changed-category alert, one
recovery, transient suppression, TLS/connect/timeout/HTTP/size/JSON failures,
corrupt private state, URL rejection and absence of sensitive output.
- Run Python compile and shell syntax checks, validate the five-minute calendar,
and assert disabled-by-default plus exact managed targets and retained state.
- For a remote rollout, record boolean evidence for pre-write `sudo -n`, exact
temporary-upload removal, temporary privilege removal and post-removal
non-interactive-sudo failure; never record the password, host address or rule
contents.
- Re-run `bash scripts/qa/run-automatic-backup-contract.sh` whenever the shared
helper allowlist or behavior changes.
- ARM install, enabled/active timer status and real failure/recovery messages are
external evidence and may not be inferred from the local contract.
### 7. Wrong vs Correct
#### Wrong
```bash
# Local contract passed, so install and enable remotely and call it active.
bash deploy/trialrun/install-offhost-app-health-monitor.sh enable
```
#### Correct
```bash
bash scripts/qa/run-app-health-monitor-contract.sh
bash scripts/qa/run-automatic-backup-contract.sh
# After separate ARM-write approval: install disabled, inspect, then seek
# enable/real-message/drill approval before claiming the monitor is active.
<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"]}
```
@@ -36,6 +36,108 @@ and verification contracts. Do not copy broad aesthetic guidance from
with `AUTH_005`.
- Run `cd client && npm run typecheck && npm run build` for frontend changes.
## Scenario: UTC Date-Time Display In The Company Time Zone
### 1. Scope / Trigger
- Trigger: a frontend page displays an API timestamp such as attachment upload,
approval action, payment or audit time. API timestamps remain ISO 8601 UTC;
the company-facing OA displays date-times in `Asia/Shanghai`.
### 2. Signatures
- Shared formatter: `formatDisplayDateTime(value: string | null | undefined): string`.
- Date-only values continue to use `formatDisplayDate`; do not turn business
dates such as reimbursement or invoice dates into timezone-shifted instants.
### 3. Contracts
- Date-time pages reuse `client/src/lib/displayFormat.ts`; do not add a
page-local formatter for the same contract.
- `Intl.DateTimeFormat` must set `timeZone: "Asia/Shanghai"` explicitly so the
result does not depend on the browser, workstation or CI timezone.
- Database storage and API ISO strings remain unchanged.
### 4. Validation & Error Matrix
- Missing value -> `-`.
- Invalid date-time -> the shared display fallback; never throw during render.
- Valid UTC value -> Shanghai calendar date and clock time.
- Browser outside China -> the same Shanghai result as a browser in China.
### 5. Good/Base/Bad Cases
- Good: `2026-05-27T09:10:00.000Z` displays as `2026年5月27日 17:10`.
- Base: a null attachment or approval time displays `-`.
- Bad: `value.slice(0, 19).replace("T", " ")`, which presents UTC clock
fields as if they were local time.
### 6. Tests Required
- Focused Playwright uses synthetic UTC data and a browser `timezoneId` outside
Shanghai, then asserts the exact Shanghai attachment/approval time.
- Run `cd client && npm run typecheck && npm run build`.
### 7. Wrong vs Correct
#### Wrong
```ts
const displayed = value.slice(0, 19).replace("T", " ");
```
#### Correct
```ts
const displayed = formatDisplayDateTime(value);
```
## Scenario: Shared RMB Display
### 1. Scope / Trigger
- Trigger: a reimbursement, invoice, fee-control or payment UI displays a CNY amount received as a string, number or nullable field.
### 2. Signatures
- Shared formatter: `formatMoney(value: string | number | null | undefined): string` in `client/src/lib/displayFormat.ts`.
### 3. Contracts
- Reuse the shared formatter across PC and mobile consumers; do not add page-local `Intl.NumberFormat` helpers for the same amount.
- Valid amounts display the CNY symbol, thousands separators and exactly two decimal places. Missing, empty or non-finite values display `-`; they must not become `¥0.00`.
### 4. Validation & Error Matrix
- `1860` / `"1860"` -> `¥1,860.00`.
- `"550.5"` -> `¥550.50`.
- `null`, `""`, invalid text or a non-finite number -> `-`.
### 5. Good/Base/Bad Cases
- Good: a PC detail total, mobile detail total and invoice summary all use `formatMoney`.
- Base: a real zero amount displays `¥0.00`.
- Bad: use string interpolation such as `` `¥${amount}` `` or coerce a missing amount with `Number(value ?? 0)`.
### 6. Tests Required
- Focused frontend regression covers integer, fractional and thousands-separated amounts on PC and mobile.
- Run `cd client && npm run typecheck && npm run build`.
### 7. Wrong vs Correct
#### Wrong
```tsx
<span>¥{detail.totalAmount ?? "-"}</span>
```
#### Correct
```tsx
<span>{formatMoney(detail.totalAmount)}</span>
```
## Convention: Frontend Design Token Sources
**What**: Runtime visual tokens use the sources that are actually consumed by
@@ -168,6 +270,68 @@ reference, not a copy of Feishu or Ant Design branding.
| `请假记录` / `考勤` | 业务台账列表 or record view | Enterprise WeChat-owned records, mirror status, exceptions and audit | Ordinary users do not see OA daily leave/punch approval creation; OA emergency entry only for authorized emergency/admin scope. |
| Mobile routes | Mobile expression of the same template | Top status/focus, card list or sectioned detail | One bottom safe-area primary action; secondary actions in `更多` or confirmation. |
## Scenario: Approval Task Business Detail Exit
### 1. Scope / Trigger
- Trigger: an approval task detail shows only a business summary while the approver must inspect line items, invoices, attachments or other evidence before acting.
### 2. Signatures
- Mobile reimbursement target: `/mobile/reimbursements/:reimbursementId`.
- PC reimbursement target: `/reimbursements/:reimbursementId`.
- Mobile source state: `<Link state={{ from: currentMobileSource }}>`.
- PC source state: `<Link state={{ from: currentPcSource }}>`; `currentPcSource` is the trusted internal `pathname + search` of the current reimbursement list or approval task.
### 3. Contracts
- The pending-task list card continues to open `/mobile/approvals/:taskId`; do not confuse task selection with the separate business-detail exit inside the task detail.
- When `WorkflowTaskDetail.reimbursement` exists, the task detail exposes an explicitly named `查看完整报销单` or `查看报销单` link near the reimbursement summary.
- Reuse the existing business detail route and its permissions. Do not duplicate reimbursement items, invoice or attachment viewers inside the approval page merely to add reachability.
- Mobile navigation passes the current approval task URL through the existing `state.from` convention so the business detail back button returns to that task. This is a one-level source contract, not a new nested return stack.
- PC reimbursement list and task-detail exits use the same one-level `state.from` contract. The detail page accepts only `/reimbursements` list URLs and `/workflow/tasks/:id`; absolute/protocol-relative URLs, hashes, other modules and the current page are rejected. A direct open or refresh safely falls back to `/reimbursements`.
- PC detail return is a visible text action immediately before the detail hero, uses `replace`, and is not duplicated inside the business primary-action group or `更多`.
- Approval actions remain on the approval task page. Opening the business detail does not approve, reject, return or mutate the task.
### 4. Validation & Error Matrix
- Task has a reimbursement -> show the reimbursement-detail link.
- Task is another entity type -> do not show a reimbursement link; add that entity's route only in a scoped task.
- Reimbursement summary is absent -> render no broken or guessed target.
- Business detail permission fails -> preserve the existing permission-denied behavior; do not expand roles from the frontend.
### 5. Good/Base/Bad Cases
- Good: approver opens the task, follows `查看完整报销单`, inspects evidence, returns to the same task and then chooses an approval action.
- Base: a completed reimbursement task still provides the read-only business-detail exit when its existing detail permission allows it.
- Bad: make a static-looking summary card secretly clickable, change the pending-list click handler, or copy a partial invoice/attachment viewer into the task page.
### 6. Tests Required
- Mobile E2E: task detail -> reimbursement detail -> items/evidence visible -> back to the same task -> approval actions still visible.
- PC E2E: filtered reimbursement list -> task/detail -> visible source-aware return; the list path retains its query string, task detail returns to the same task, and direct-open detail falls back to the reimbursement list.
- Assert non-reimbursement task details do not receive a reimbursement target.
- Run `cd client && npm run typecheck && npm run build`.
### 7. Wrong vs Correct
#### Wrong
```tsx
<div onClick={() => handleTaskSelection(task.id)}>报销摘要</div>
```
#### Correct
```tsx
<Link
to={`/mobile/reimbursements/${encodeURIComponent(reimbursement.id)}`}
state={{ from: currentMobileSource }}
>
查看完整报销单
</Link>
```
**Implementation order for design refactors**:
1. A-class frequent pages: workbench, pending tasks, workflow task detail,
@@ -1649,6 +1813,90 @@ onNeedRefresh() {
}
```
## Scenario: Live WebView Build Freshness
### 1. Scope / Trigger
- Trigger: a production browser, PWA or Enterprise WeChat WebView can remain
alive across deployment and continue running an old SPA bundle even though
public `index.html`, service worker and immutable chunks are current.
- A public bundle containing new copy proves deployment, not that every live
WebView has loaded that bundle.
### 2. Signatures
- Client build: `document.documentElement.dataset.buildSha`, sourced from
`VITE_BUILD_SHA` and normalized to 12 hexadecimal characters.
- Server check: `GET /ready` -> `{ data: { ok: true, buildSha: string } }`.
- Resume event: document `visibilitychange` with `visibilityState="visible"`.
- Prompt event: `hyb-oa:pwa-update-ready` with `{ applyUpdate }`.
### 3. Contracts
- Check only when a loaded page returns to the foreground; do not add a timer
or poll continuously.
- Request `/ready` with `cache: "no-store"`; compare only valid hexadecimal
build identifiers and never display the identifiers to ordinary users.
- A mismatch dispatches the existing update-ready event once per page
lifecycle. The logged-in shell shows `发现新版本` with an explicit
`点击刷新`; it must not auto-reload an approval, form or auth input.
- The refresh handler clears known PWA runtime/precache caches and then reloads
the current URL. Existing auth-page update protection remains in force.
- This monitor does not replace deployment build checks or real-device
verification; it closes the live-client freshness gap after deployment.
- The first deployment of this monitor cannot notify a WebView that is still
executing an older bundle without the monitor. That rollout still requires
an explicit manual refresh instruction; later deployments gain the guard.
### 4. Validation & Error Matrix
- Client and server builds match -> no prompt.
- Valid builds differ -> show one prompt.
- `/ready` is unavailable, non-2xx, malformed or missing `buildSha` -> stay
silent and preserve the current page.
- Client build is `development`, `unknown`, empty or malformed -> stay silent.
- Repeated foreground events after a mismatch -> no duplicate request or
prompt in the same page lifecycle.
### 5. Good/Base/Bad Cases
- Good: a manager returns to an old approval WebView, sees one refresh prompt,
chooses refresh and then receives the deployed approval-detail bundle.
- Base: the deployed and client builds match, so returning to the app adds no
UI or network loop beyond the single readiness check.
- Bad: infer that a missing button is a permission problem when adjacent UI
from the same unconditional JSX block proves an old bundle is running.
- Bad: force-refresh every foreground event or build a general update center.
### 6. Tests Required
- Focused Playwright with synthetic builds covers mismatch, match and failed
`/ready` responses.
- Mismatch coverage asserts one prompt, no duplicate check in the same page,
user-triggered reload, a 44px refresh target and no horizontal overflow at
375px portrait and phone landscape widths.
- Run the full affected login/portal UX file, frontend typecheck and build.
- Production acceptance still requires the representative Enterprise WeChat
WebView to refresh and visibly receive the new business entry.
### 7. Wrong vs Correct
#### Wrong
```ts
setInterval(() => window.location.reload(), 60_000);
```
#### Correct
```ts
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
void checkBuildFreshness();
}
});
```
## Scenario: Auth Page Layout And Login Entry Hierarchy
### 1. Scope / Trigger
@@ -1664,7 +1912,10 @@ onNeedRefresh() {
- Auth routes should use one shared auth panel for brand, page title, subtitle,
content and optional footer links.
- `AuthLayout` owns only the auth page shell: background, width constraint,
centering, PWA update prompt and route outlet.
centering, PWA update prompt and route outlet. Its default variant remains a
460px auth form; `/login` may explicitly select the `login` variant to allow
the approved desktop entry layout without widening password recovery or
forced password change pages.
- Page implementations own only their form fields, submit actions, validation
messages and route-specific login-entry choices.
@@ -1673,8 +1924,12 @@ onNeedRefresh() {
- Do not nest a form card inside another login card.
- Put the brand block inside the auth panel so logo, system name, title and
form share one visual axis.
- Keep the panel width stable around the current auth-shell width; auth pages
should feel like an OA workstation entry, not a marketing split hero.
- Keep password recovery, forced password change and login fallback-only panels
stable around the 460px auth-shell width. When PC Enterprise WeChat login is
enabled, `/login` may expand to a functional desktop split up to roughly
920px: Enterprise WeChat entry on the left and a form column capped around
404px on the right. This remains one shared auth panel, not a marketing split
hero or nested form-card layout.
- Place `忘记密码?` beside the password label or directly below the password
input on the right; do not bury it in help text or page footer.
- Keep the DOM/tab order aligned with the main form flow even when CSS places
@@ -1686,10 +1941,15 @@ onNeedRefresh() {
### 4. Login Entry Hierarchy
- Normal browser: account/password or email-code login is primary. Enterprise
WeChat is shown as a boundary notice or secondary entry that tells users to
open OA from the Enterprise WeChat workbench; do not require a clickable OAuth
button in ordinary browsers.
- Normal browser with `pcWebLoginEnabled=true` and a non-null `pcLoginUrl`:
Enterprise WeChat is the preferred desktop entry. At 1024px and above, show
it in the left functional column and keep account/password plus email-code in
the right fallback column. Below 1024px, preserve the existing single-column
form-first order and show the PC Enterprise WeChat entry afterward.
- Normal browser without an available PC login URL: keep the existing 460px
single-column form and show only the workbench boundary notice or other
configured secondary provider; never render an empty desktop column or a
disabled PC login button.
- Enterprise WeChat WebView: Enterprise WeChat one-click entry is primary;
account/password login remains available as the fallback path.
- Enterprise WeChat entry changes must remain frontend entry hierarchy changes
@@ -1706,7 +1966,10 @@ onNeedRefresh() {
- keyboard Tab from the username/email field lands on the password input, not
the forgot-password link;
- password recovery request-code and reset flow on `/forgot-password`;
- normal-browser Enterprise WeChat boundary notice or secondary entry;
- 1024px and 1440px normal-browser PC Enterprise WeChat functional split,
including column position and controlled form width;
- 375px and 768px single-column form-first order without horizontal overflow;
- PC login configuration unavailable falling back to the 460px single column;
- WeCom WebView Enterprise WeChat entry as primary CTA with password fallback.
- UI design rules check and Playwright screenshot evidence when the auth layout
changes visibly.
@@ -1790,3 +2053,57 @@ hierarchy without turning every page into a bespoke Tailwind card system.
- Focused Playwright coverage for changed finance/admin pages. The Wave4
baseline test is `client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts`.
- UI design rules check when changing these shared classes.
## Scenario: Frontend Acceptance Spec Gate Registration
### 1. Scope / Trigger
- Trigger: a frontend E2E spec is treated as a current acceptance contract rather than historical evidence or a manual-only diagnostic.
### 2. Signatures
- Manifest entry: `scripts/qa/route-manifest.json` → `acceptanceSpecs[]`.
- Executable gate: `bash scripts/qa/run-local-candidate.sh list|focused|full`.
### 3. Contracts
- A current acceptance spec must be referenced by an executed gate; merely existing under `client/tests/e2e/` is not coverage.
- `list` must resolve the spec, while `focused` or `full` must execute it and reject failed or skipped acceptance results.
- DOM discovery may be dynamic to avoid duplicating selectors, but the expected business set, order or invariant must remain explicit. Never accept whatever the page happens to render.
- Historical or unowned specs must be deliberately classified for later connection or deletion; do not silently present them as active coverage.
### 4. Validation & Error Matrix
- Manifest entry missing or invalid → flow-coverage gate fails.
- Acceptance spec skipped, failed or timed out → local-candidate result gate fails.
- Dynamic collection with no fixed expectation → review failure even if the test is green.
- Gate fixture cannot execute a newly called child script → fixture test fails and the child script must be added through command discovery or explicit setup.
### 5. Good/Base/Bad Cases
- Good: discover current action tiles from the grid, then assert the fixed role-specific key/label set and layout invariants.
- Base: a stable single control uses a direct `data-testid` assertion and is listed in `acceptanceSpecs`.
- Bad: count whatever matching nodes exist, assert only their individual dimensions, and leave the spec outside every runner.
### 6. Tests Required
- `node --test scripts/qa/tests/check-flow-coverage.test.mjs scripts/qa/tests/check-minimal-profile.test.mjs scripts/qa/tests/verify-local-candidate-results.test.mjs`.
- `bash scripts/qa/run-local-candidate.sh list` must list the spec.
- `bash scripts/qa/run-local-candidate.sh focused` or `full` must finish with zero failed/skipped acceptance tests.
### 7. Wrong vs Correct
#### Wrong
```ts
const actions = collectVisibleActions();
for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44);
```
#### Correct
```ts
const actions = collectVisibleActions();
expect(actions.map(({ key, label }) => ({ key, label }))).toEqual(EXPECTED_ACTIONS);
for (const action of actions) expect(action.height).toBeGreaterThanOrEqual(44);
```
@@ -15,6 +15,771 @@ duplicate detection or attachment behavior.
submission, approval, payment batch, receipt attachment and AP export when the
Trellis task scope touches those areas.
## Scenario: OCR Disabled Without Mock Data
### 1. Scope / Trigger
- Trigger: the production OCR provider is unavailable or intentionally disabled
while employees still need to upload PDF invoices and submit reimbursements.
- Purpose: keep real invoices out of the mock provider and avoid interpreting a
historical database default as evidence that Tencent OCR actually ran.
### 2. Signatures
- Runtime env: `OCR_PROVIDER=off`.
- Upload: `POST /api/invoice-pool/upload` with a PDF multipart `file`.
- Bind: `POST /api/invoice-pool/:id/bind` with `{ reimbursementId }`.
- Database: `InvoicePool.ocr_provider` is a non-null `OcrProvider` with schema
default `tencent`; `ocr_attempts`, `ocr_last_requested_at`,
`ocr_completed_at` and extracted invoice fields carry the execution evidence.
- Duplicate index: `@@unique([file_sha256, ocr_provider, ocr_type])` partitions
the SHA-256 namespace by provider and OCR type.
### 3. Contracts
- `off` means no OCR queue entry, no provider request and no invented invoice
code, number, date, buyer/seller or amount. A new PDF enters `available` and
may be bound to an existing reimbursement item.
- `mock` is restricted to local or explicitly isolated synthetic tests. It must
not process a real production invoice.
- Because `ocr_provider` is non-null with a database default, an off-mode record
may still store `tencent` when the create payload omits that field. Consumers
must not treat that value alone as proof of OCR execution.
- Until real OCR or mandatory manual metadata entry is separately approved,
finance checks the PDF original manually. SHA-256 duplicate detection remains
active; invoice-code/number duplicate detection is unavailable for blank
metadata.
- A future nullable/off-provider migration must not change only the stored
provider value. Backfill historical rows to one canonical namespace or replace
the composite unique key with an intentional file-level uniqueness contract;
otherwise the same bytes can be uploaded once before and once after migration.
- Before changing provider semantics, count historical `mock` rows uploaded by
non-test employees. Any such rows need an explicit dedupe/review plan because
their provider namespace already differs from current default rows.
- A config-only provider switch keeps `BUILD_SHA` unchanged. Recreate every
process that actually loads OCR config: server only when `JOBS_ENABLED=true`
and no worker exists; server plus worker when an independent worker already
exists. Never create a second job executor merely to satisfy a generic restart
instruction.
### 4. Validation & Error Matrix
- `OCR_PROVIDER=mock` in real operation -> block real invoice use and switch to
`off` or a verified live provider.
- `status != available` immediately after an off-mode upload -> stop and inspect
the effective runtime config; do not patch invoice fields to bypass it.
- Any extracted invoice business field, OCR attempt, request timestamp or
completion timestamp appears in off mode -> fail the acceptance run and
investigate provider/job topology.
- `ocr_provider=tencent` with attempts `0`, null request/completion timestamps
and null extracted fields -> historical schema-default finding, not evidence
of a Tencent request.
- Provider enum/default or unique-key change without backfill/index redesign ->
block migration review because it splits the duplicate-detection namespace.
- Blank metadata cannot bind or submit -> keep the test draft, clean it through
normal APIs, then plan a code change; do not add an emergency production
database edit.
### 5. Good/Base/Bad Cases
- Good: off-mode PDF is `available`, all business fields are null, attempts are
`0`, it binds to a 0.01 synthetic reimbursement, submits, is returned, then
the reimbursement is deleted and the invoice archived.
- Base: live Tencent OCR is configured and verified with an authorized sample;
normal extracted-field and duplicate rules apply.
- Bad: leave production on mock, or decide that OCR ran merely because
`ocr_provider` contains the schema default `tencent`.
### 6. Tests Required
- Assert upload status is `available` under `OCR_PROVIDER=off`.
- Assert invoice code, number, date, buyer, seller and all amount fields are
null; assert attempts are `0` and request/completion timestamps are null.
- Migration tests for provider semantics must upload identical bytes on both
sides of the migration and assert that the second upload is still rejected or
reuses the canonical row.
- Bind to a reimbursement with one valid item, submit through the real workflow,
return instead of approve, delete the draft and archive the released invoice.
- Final read-only checks: test accounts archived, active sessions `0`, test
reimbursements `0`, pending tasks `0`, non-archived test invoices `0`, and
Enterprise WeChat writeback remains skipped.
### 7. Wrong vs Correct
#### Wrong
```js
const ocrRan = invoice.ocr_provider === "tencent";
```
#### Correct
```js
const ocrRan = invoice.ocr_attempts > 0
|| invoice.ocr_last_requested_at != null
|| invoice.ocr_completed_at != null
|| invoice.ocr_result_id != null;
```
## Scenario: Tencent General Invoice OCR Safe Persistence
### 1. Scope / Trigger
- Trigger: production uses Tencent OCR for employee-uploaded PDF invoices,
including VAT/electronic ordinary invoices and railway electronic tickets.
- Use the single `RecognizeGeneralInvoice` entry point. Do not split railway
tickets onto the legacy train-ticket API or fall back to synthetic OCR.
- This contract applies to upload limits, provider requests, normalization,
required-field validation, duplicate detection, cache reuse, retry behavior
and database persistence.
### 2. Signatures
- Runtime env: `OCR_PROVIDER=tencent`, `TENCENT_SECRET_ID`,
`TENCENT_SECRET_KEY`, `TENCENT_OCR_REGION` and `OCR_TIMEOUT_MS`.
- Upload: `POST /api/invoice-pool/upload` with one
`application/pdf` multipart `file`, at most `7 * 1024 * 1024` bytes.
- Provider call:
`runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType })` calls
`client.RecognizeGeneralInvoice(request)`.
- Persistence boundary:
`sanitizeOcrResultForPersistence(ocrProvider, normalized)` must run before
every Tencent `OcrResult.result_json` create, cache return or cache repair.
- Duplicate boundary:
`shouldDeduplicateByInvoiceNumberOnly(normalized)` is true only for a known
no-code category with a non-empty invoice number.
### 3. Contracts
- Each PDF contains one invoice. Tencent receives page 1 only with
`EnablePdf=true`, `PdfPageNumber=1`, `EnableMultiplePage=false`,
`EnableCutImage=false`, `EnableItemPolygon=false`, `EnableQRCode=false`,
`EnableSeal=false` and `EnableOther=false`.
- Supported categories are `vat_invoice`, `full_vat_invoice` and
`electronic_train_ticket` (`ElectronicTrainTicketFull`). Other subtypes and
multiple detected invoices fail closed.
- Required normalized fields:
- `vat_invoice`: `invoiceCode`, `invoiceNumber`, `invoiceDate`,
`totalAmount`, `sellerName`.
- `full_vat_invoice`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`sellerName`.
- `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`buyerName`.
- Railway `totalAmount` uses the first individually parseable value from
Tencent `Total`, then `Fare`. Parse each field before applying fallback:
an empty or otherwise unparseable `Total` must not block a parseable `Fare`.
- Never invent an invoice code from an invoice number or vice versa. Known
no-code full-VAT and railway tickets use invoice-number-only duplicate
detection; other categories retain code-plus-number semantics.
- Tencent persistence is allow-list only: document subtype/category/count,
normalized invoice identifiers/date/amounts/buyer/seller, approved quality
error and `pdf_native` transport mode. Never persist the raw provider
response, passenger name, identity number, QR/seal data or full ticket fields.
- Existing Tencent cache rows are not trusted as already safe. Every cache hit,
including the unique-key race path, is re-sanitized and written back before
use.
- OCR remains assistive. Finance must compare the reimbursement item, source
PDF and required payment evidence; recognition success is not automatic
approval.
### 4. Validation & Error Matrix
- Non-PDF source -> terminal `unsupported_source_mime_type`; do not retry.
- PDF larger than 7 MiB -> upload rejects with
`PDF 发票不能超过 7 MiB`; do not enqueue OCR.
- No invoice / more than one invoice / unsupported subtype / provider item not
`OK` -> `invoice_not_detected` /
`multiple_invoices_not_supported` / `unsupported_invoice_subtype` /
`invoice_recognition_failed`; mark OCR failed without inventing fields.
- Missing per-category fields ->
`missing_required_fields:<comma-separated-fields>`; mark OCR failed.
- `AuthFailure*`, `UnauthorizedOperation*`, `InvalidParameter*`,
`InvalidParameterValue*`, `FailedOperation.UnOpenError`,
`LimitExceeded.TooLargeFileError`,
`ResourceUnavailable.ResourcePackageRunOut`,
`ResourceUnavailable.InArrears` and
`ResourcesSoldOut.ChargeStatusException` -> terminal failure; do not consume
retry attempts blindly or switch to a billable/ mock provider.
- A transient provider/network error not in the terminal allowlist -> retain the
bounded existing retry policy; never expose credentials or raw responses in
the user-facing error.
### 5. Good/Base/Bad Cases
- Good: one railway electronic ticket PDF under 7 MiB yields
`electronic_train_ticket`, a blank code, a valid number/date/total/buyer and
participates in number-only duplicate detection; persisted JSON contains no
passenger identity fields or raw response.
- Base: one supported VAT/electronic ordinary invoice yields its required
category fields and follows the normal code-plus-number or known no-code
duplicate rule.
- Bad: send a multi-invoice PDF, parse only the first detected invoice, copy
number into code, persist `raw`, or retry after the free resource package is
exhausted.
### 6. Tests Required
- Adapter unit tests assert the exact `RecognizeGeneralInvoice` request flags,
PDF-only transport and normalization for VAT, full VAT and
`ElectronicTrainTicketFull`.
- Adapter error tests assert terminal classification for credentials,
authorization, invalid input, oversized input, service-not-open,
arrears/charge state and free-package exhaustion.
- OCR service/job tests assert category-specific required fields,
number-only dedupe only for known no-code categories, and no retry on terminal
errors.
- Railway adapter tests must cover an empty `Total` with a parseable `Fare` and
assert that normalized `totalAmount` uses `Fare` without emitting the source
amount in diagnostics.
- Cache tests seed a legacy Tencent result containing `raw` and sensitive
fields, then assert both normal and race cache paths return and persist only
the allow-listed shape.
- Upload route tests assert exactly 7 MiB remains accepted and any larger PDF
is rejected before OCR enqueue.
- Full backend regression, Prisma validation, sensitive-content check and
Trellis governance check remain required before commit.
### 7. Wrong vs Correct
#### Wrong
```js
const result = await client.VatInvoiceOCR({ ImageBase64: imageBase64 });
await prisma.ocrResult.create({ data: { result_json: { ...result, raw: result } } });
```
#### Correct
```js
const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
const normalized = normalizeGeneralInvoiceResult(result);
const persisted = sanitizeOcrResultForPersistence("tencent", normalized);
await prisma.ocrResult.create({ data: { result_json: persisted } });
```
For railway amount fallback, do not choose the raw source before parsing:
```js
// Wrong: an empty string is not nullish, so it blocks Fare.
toNumberString(info?.Total ?? info?.Fare);
// Correct: only a successfully parsed Total blocks Fare.
toNumberString(info?.Total) ?? toNumberString(info?.Fare);
```
## Scenario: Live OCR Sample Stdin Probe
### 1. Scope / Trigger
- Trigger: an authorized real PDF must prove Tencent credentials, network,
subtype normalization and required-field behavior without creating an OA
invoice or persisting the sample on the execution host.
- This probe is intentionally one-file and PDF-only. It does not retain the
historical manifest, directory scan, image evaluation or legacy VAT-only
command modes.
### 2. Signatures
- Command:
`node scripts/qa/check-ocr-provider-samples.mjs --alias=SAMPLE-PDF-<SAFE_ALIAS>`.
- Input: exactly one PDF on stdin, at most `OCR_PDF_MAX_BYTES` (7 MiB).
- Runtime env: `TENCENT_SECRET_ID`, `TENCENT_SECRET_KEY`, optional
`TENCENT_OCR_REGION` and `OCR_TIMEOUT_MS`; provider is fixed to `tencent`.
- Provider boundary: `runOcrForInvoice({ config, ocrType: "vat",
imageBase64, sourceMimeType: "application/pdf" })`.
- Output: one JSON object containing only `alias`, `provider`, `status`,
`transportMode`, `elapsedMs`, safe document category/subtype/count,
per-field hit booleans, missing field names, an optional railway amount-field
boolean diagnostic and a bounded error code.
### 3. Contracts
- The caller selects one authorized PDF explicitly and streams it through
stdin. The probe never accepts a file path, directory, manifest, image or
batch list and never writes sample bytes to disk, OA storage or the database.
- Alias must match `SAMPLE-PDF-[A-Z0-9_-]+`; it replaces the original file name
in every output and evidence record.
- Validate the 7 MiB limit, PDF version header and terminal `%%EOF` before
constructing Base64 or invoking Tencent. One process performs at most one
provider call.
- Reuse `RecognizeGeneralInvoice`, `sanitizeOcrResultForPersistence` and the
production category-specific required-field rules. The probe must not carry
a parallel VAT-only field list.
- Never output recognized identifiers, dates, amounts, buyer/seller/passenger
names, provider messages, raw response or input bytes. Field presence is a
boolean; errors are bounded stable codes.
- When a railway result lacks `totalAmount`, the adapter may expose an
ephemeral diagnostic for the documented Tencent `Total` and `Fare` fields.
It contains only presence, string/number type, non-empty and current-parser
acceptance booleans. The live runner re-allowlists those booleans; the
persistence sanitizer must drop the complete diagnostic object before every
cache or database write.
- The JSON summary is the primary evidence even when the probe exits non-zero.
Shell callers must capture and persist the bounded stdout summary before
interpreting the exit status; do not let `set -e` discard a valid HOLD
summary held only in a command-substitution variable.
- A PASS proves the direct provider/normalization path only. It does not prove
OA upload, queue, persistence, duplicate detection, binding or reimbursement
submission.
### 4. Validation & Error Matrix
- Missing/invalid alias -> `sample_alias_invalid`; no provider call.
- Empty stdin -> `sample_empty`; no provider call.
- More than 7 MiB -> `sample_too_large`; stop reading and do not call provider.
- Invalid header or missing terminal EOF -> `sample_pdf_invalid`; no provider
call.
- Missing credentials or provider mismatch -> `ocr_credentials_missing` /
`ocr_provider_invalid`; no business values in output.
- Supported subtype with missing category-required fields -> `status=HOLD`
plus field names only.
- Tencent failure -> `status=HOLD` plus a bounded SDK error code; omit the
provider message and raw error object.
### 5. Good/Base/Bad Cases
- Good: stream one railway electronic ticket using an anonymous alias; receive
`electronic_train_ticket`, boolean field hits and no recognized values or
persisted sample.
- Base: stream one supported accommodation invoice and receive a PASS summary
for its VAT/full-VAT category.
- Bad: point the probe at a directory, auto-run every PDF, write a manifest with
real paths, accept images, print OCR values or retain the sample remotely.
### 6. Tests Required
- Unit tests inject synthetic provider results for VAT, full VAT and railway
categories and assert category-aware required fields.
- Negative tests assert non-PDF, truncated PDF and oversized input fail before
the provider mock is called.
- Privacy tests seed raw provider and recognizable normalized markers, then
assert serialized output contains only booleans and safe metadata.
- Railway amount-diagnostic tests cover absent, present-but-unparseable and
parseable fields, assert source values never reach stdout, and assert
`sanitizeOcrResultForPersistence` removes diagnostics completely.
- CLI test pipes one synthetic PDF over stdin with credentials absent and
asserts one parseable redacted error object with empty stderr.
- Caller capture test asserts a redacted HOLD JSON remains available when the
probe exits `1`; no provider retry may be inferred solely from lost stdout.
- Before deployment run `node --check`, the focused tests, full backend tests,
sensitive-content scan, task validation and Trellis governance.
### 7. Wrong vs Correct
#### Wrong
```bash
PLAYWRIGHT_OCR_LIVE_DIR=<sample-dir> \
node scripts/qa/check-ocr-provider-samples.mjs --provider=tencent --json
```
#### Correct
```bash
node scripts/qa/check-ocr-provider-samples.mjs \
--alias=SAMPLE-PDF-TRAIN < <authorized-single-sample.pdf>
```
The caller records stdout before applying the exit-code gate; it never uses a
`set -e` assignment whose non-zero status exits before the JSON is emitted.
## Scenario: Travel Allowance Expense-Type Boundary
### 1. Scope / Trigger
- Trigger: adding or changing travel reimbursement types, travel-request
transport mappings, allowance policies or expense standards.
- Purpose: keep intercity transport reimbursed by evidence separate from the
two daily lump-sum allowances, and avoid false over-limit results until the
reimbursement item model supports a day count.
### 2. Signatures
- Seed contract in `server/src/scripts/seed-config.js`:
- `travel_bus` -> `大巴车票`.
- `travel_meal` -> `伙食补助费`.
- `travel_local_transport_allowance` -> `市内交通补助费`.
- Travel conversion contract in `server/src/routes/reimbursements.js`:
`TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES.bus === "travel_bus"`.
- Current item data boundary: `ReimbursementItem` stores expense type, date,
amount and description, but no quantity or eligible-day count.
### 3. Contracts
- `travel_bus` always means an intercity bus/coach ticket created from or used
with a travel segment. It must not represent local transport allowance.
- Meal allowance is CNY 100 per eligible person-day; local transport allowance
is CNY 80 per eligible person-day. Both are lump-sum items without an invoice.
- Employees create one item per allowance, set the amount to eligible days
multiplied by the rate, and write the date range, day count, rate and formula
in the description. Supervisor and finance review that calculation.
- Departure and return dates each count as one natural day; same-day return is
one day. Personal extensions do not count. Meals or vehicles paid directly
by the company or another organization exclude the corresponding allowance.
- Claiming meal allowance excludes separate personal meal reimbursement;
claiming local transport allowance excludes bus, metro, taxi and ride-hailing
reimbursement during the trip. Intercity transport, accommodation and other
approved necessary expenses remain evidence-based actual reimbursement.
- `ExpenseStandard.per_unit=day` currently does not multiply `max_amount` by a
day count. Do not seed or configure CNY 100/CNY 80 warn/block standards until
item quantity/day multiplication is implemented end to end.
### 4. Validation & Error Matrix
- Travel segment `bus` maps to an allowance type -> block the change; preserve
`travel_bus` and add/use the separate allowance code.
- CNY 100 or CNY 80 daily rate configured as the current item-level maximum ->
remove/disable it because a multi-day allowance would be falsely over limit.
- Allowance item lacks date range, eligible-day count, rate or formula -> return
for completion during supervisor/finance review; do not invent a day count.
- Company/other party directly bore the corresponding cost -> exclude those
days from the applicable allowance and record the reason.
### 5. Good/Base/Bad Cases
- Good: a three-day eligible trip uses separate meal and local-transport items
whose descriptions show `3 天 × 单价`, while rail/hotel items carry PDFs and
required payment evidence.
- Base: intercity coach travel uses `travel_bus` / `大巴车票` and remains an
actual-expense item with valid evidence.
- Bad: rename `travel_bus` to local transport allowance, or configure a CNY 80
item maximum and thereby reject a valid multi-day allowance.
### 6. Tests Required
- Seed-default unit test asserts all three codes and Chinese names are distinct.
- Travel-request conversion test asserts `transport_type=bus` produces an item
whose expense type code is `travel_bus` and description identifies `大巴`.
- When automatic day calculation is later implemented, add service and route
tests for same-day, multi-day, personal-extension and directly-borne cases
before enabling any daily standard.
- Documentation checks must keep the employee guide, reimbursement fact source
and production expense-type names aligned.
### 7. Wrong vs Correct
#### Wrong
```js
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_local_transport_allowance" };
// Current fee control compares the whole multi-day item directly with 80.
```
#### Correct
```js
const TRAVEL_TRANSPORT_EXPENSE_TYPE_CODES = { bus: "travel_bus" };
// Local transport allowance is a separate manually calculated item until the
// model and fee-control service both support eligible-day multiplication.
```
## Scenario: Reimbursement Attachment Batch And UTF-8 Filename Boundary
### 1. Scope / Trigger
- Trigger: changing reimbursement supporting-material upload limits, multipart
parsing, attachment/invoice file responses or tests that register multipart.
- Purpose: preserve the global one-file safety default while supporting the
reimbursement UI's bounded multi-file request and legal Chinese file names.
### 2. Signatures
- Upload: `POST /api/reimbursements/:id/attachments`, multipart field `files`,
one to five PDF/JPG/PNG/WebP files per request.
- Route parser: `request.parts({ limits: { files: 5, fileSize } })`; the global
`@fastify/multipart` registration remains `limits.files=1`.
- Download headers: `buildAttachmentContentDisposition(name, {
disposition: "inline" })` for reimbursement attachments and invoice PDFs.
- Client constant: `MAX_REIMBURSEMENT_ATTACHMENT_FILES=5` is shared by PC and
mobile reimbursement detail pages.
### 3. Contracts
- Do not relax the application-wide multipart file count. Only the
reimbursement supporting-material route overrides it to five.
- Parse the complete multipart request before storage/database writes so a
sixth file cannot leave a partial five-file upload.
- `FST_FILES_LIMIT` maps to `400 VALID_001` with
`{ file: "too_many_files", maxFiles: 5 }`; PC and mobile show the same
bounded message. Generic error mapping must not assume every module has the
reimbursement limit when `maxFiles` is absent.
- Preserve the original name in the database. HTTP `Content-Disposition` uses
an ASCII `filename` fallback plus RFC 5987 UTF-8 `filename*`; never place raw
Chinese, CR/LF, quotes, path separators or control characters in the legacy
quoted parameter.
- Reimbursement attachments and invoice PDFs retain `inline`; purchase,
employee and payment receipt downloads keep their existing default
`attachment` behavior through the same shared builder.
- Inside the WeCom self-built app, reimbursement invoice and attachment viewing
uses the official `previewFile` JSAPI with accurate persisted file name/size.
Because the native layer does not reliably carry OA authentication headers,
only the narrow grant defined in `wecom-boundary.md` may use a query token;
expiry is at most 120 seconds and ordinary PC downloads remain unchanged.
### 4. Validation & Error Matrix
- One to five allowed files -> parse and persist all files.
- Six or more files -> `400 VALID_001 / file=too_many_files`, with zero storage,
database and audit writes.
- Unsupported MIME, empty file or oversized file -> existing stable file
validation error; no partial persistence.
- Chinese name -> `inline; filename="attachment.<ext>"; filename*=UTF-8''...`.
- WeCom preview grant is expired, tampered or bound to another reimbursement or
attachment -> `403` with no file bytes.
- Invalid disposition value -> `TypeError`; callers may use only `inline` or
`attachment`.
### 5. Good/Base/Bad Cases
- Good: PC selects five payment screenshots; the route succeeds even though
the test app and production app both register global `files=1`.
- Base: one ASCII PDF keeps an ASCII fallback and an equivalent `filename*`.
- Bad: set the test-only global limit to five, which hides a missing route
override; or interpolate a Chinese database name directly into a Node header.
### 6. Tests Required
- Register multipart with the production global `files=1` in reimbursement
route tests, then assert five files succeed.
- Send six files and assert `400`, `VALID_001`, `too_many_files`, and no
attachment/audit writes.
- Download an existing Chinese-name reimbursement attachment and invoice PDF;
assert `inline`, ASCII `filename`, percent-encoded `filename*`, and no raw
Chinese in the header.
- Assert the WeCom grant carries the exact original name and file size, expires
within 120 seconds, records issue/access audits and rejects tamper, expiry and
object mismatch; Playwright verifies PC browser behavior and WeCom
`previewFile` behavior with synthetic files.
- Cover CR/LF, quote and path cleanup in the shared Content-Disposition helper,
while retaining existing purchase `attachment` regression tests.
- Run backend full tests and frontend typecheck/build because the limit and
error message cross API and both UI variants.
### 7. Wrong vs Correct
#### Wrong
```js
await fastify.register(multipart, { limits: { files: 5 } }); // test-only drift
reply.header("content-disposition", `inline; filename="${originalName}"`);
```
#### Correct
```js
for await (const part of request.parts({ limits: { files: 5, fileSize } })) {
// Buffer and validate the bounded request before persistence.
}
reply.header(
"content-disposition",
buildAttachmentContentDisposition(originalName, { disposition: "inline" }),
);
```
## Scenario: Reimbursement Material Image Bundle And Category-Aware Summary
### 1. Scope / Trigger
- Trigger: changing PC/mobile reimbursement material presentation, invoice OCR
category labels or the whole-reimbursement merged-preview grant.
- Purpose: keep expense items concise while allowing an approver to open one
derived PDF and continuously inspect every invoice and supporting attachment.
### 2. Signatures
- Detail payload keeps `invoice.linkedItemId` and adds the safe fields
`documentCategory`, `documentSubtype` and `buyerName`; it never exposes the
raw OCR provider response.
- Bundle issue: `POST /api/reimbursements/:id/materials/preview-bundle`, optional
`{ reason }`; response `{ expiresAt, signedUrl, originalName, mimeType,
fileSize, pageCount }`.
- Bundle fetch: `GET /api/reimbursements/:id/materials/preview/:bundleId?previewToken=...`.
- Runtime: `pdf-lib` and `sharp` execute inside Node `worker_threads`; this does
not enable the Compose worker profile and does not add a frontend PDF viewer.
### 3. Contracts
- Expense-item UI does not render or preview linked invoices. Preserve
`linkedItemId` for finance/audit semantics; presentation must not delete or
rewrite the relationship.
- The read-only detail shows one `报销材料` count and one `查看材料合并预览` action.
Editable drafts may retain per-record management rows; read-only users see
them only when continuous preview fails and the original-file fallback opens.
- Material order is invoices by `created_at,id`, then attachments by
`created_at,id`, then PDF page order. Never infer order from amount, file name
or `linkedItemId`.
- PDF originals contribute every original page; JPG/PNG/WebP materials are
normalized with `sharp` and embedded one per page. The merged PDF is a
derived approval view, never an original or digital-signature fact source.
- A stable collection hash covers the explicitly ordered material type, ID,
file hash, MIME and byte count. The cache key also includes reimbursement ID
and merger version. Fix CreationDate, ModificationDate, Creator, Producer and
save options so identical input produces byte-identical output.
- The in-memory bundle is bounded to 20 materials, 50 MiB source bytes, 40
pages, 40 megapixels per image, 50 MiB output and 100 MiB process cache.
Worker execution has a fixed timeout; concurrent requests for one collection
share one in-flight generation.
- Tokens bind purpose, reimbursement, actor, bundle and expiry. Issue requires
current object access plus any attachment download reason; bundle GET audits
before bytes and returns exact cached bytes with `private, no-store`.
- Category labels are `vat_invoice -> 增值税发票`,
`full_vat_invoice -> 数电发票`, and
`electronic_train_ticket -> 电子火车票`. Known no-code categories never show
`无代码`; railway tickets may show `buyerName` but never claim a missing seller.
### 4. Validation & Error Matrix
- No materials -> do not issue a bundle; show the existing empty state.
- Encrypted, unsupported or corrupt PDF/image, worker error or timeout -> the
entire merge fails and exposes authorized original-file fallback.
- Material/source/page/output/cache limit -> bounded validation error; never
return a partial bundle or silently omit pages.
- Missing required reason -> `400`; invalid/expired/tampered/object-mismatched
token, absent cache bundle or process restart -> `403` with no bytes.
- Bundle-issue audit failure deletes the bundle; bundle-access audit failure ->
`5xx` with no PDF body.
- Cache loss/process restart after issue -> `403`; never regenerate different
bytes behind an already-issued `fileSize`.
- Unknown or malformed OCR category -> neutral `发票` label; never guess a
category or rerun production OCR merely to fix display text.
### 5. Good/Base/Bad Cases
- Good: three invoice PDFs and three images produce one ordered six-or-more-page
PDF; WeCom and PC use their native PDF readers while originals remain intact.
- Base: one single-page PDF is copied into a one-page derived preview.
- Bad: use `ignoreEncryption`, skip a failed material, add PDF.js to the client,
expose OCR raw data, or accept the first 40 pages of a 41-page file silently.
### 6. Tests Required
- Service tests assert deterministic bytes, all-page copy, image embedding,
20–30-page worker behavior, concurrent deduplication, expiry and limits.
- Route tests assert authorization, reason gate, token binding, cache miss,
issue/access audit, audit-failure fail-closed behavior and exact PDF size.
- PC/WeCom Playwright asserts no invoice entry inside expense items, correct
category text, no `无代码/未识别销售方`, one exact `previewFile` call, fallback,
editable actions and responsive layout. Run full server tests, Prisma
validation, frontend typecheck/build and Trellis governance gates.
### 7. Wrong vs Correct
#### Wrong
```tsx
const pages = await rasterizeEveryPdfOnThePhone(); // slow, blank-prone and duplicates PDF rendering
```
#### Correct
```tsx
const bundle = await fetchReimbursementMaterialPreviewBundle(reimbursementId);
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
```
## Scenario: Lightweight Travel Summary On Reimbursements
### 1. Scope / Trigger
- Trigger: a reimbursement contains an expense item whose expense-type category
is `travel`, or links an approved travel request.
### 2. Signatures
- DB nullable columns: `travel_purpose varchar(200)`, `travel_location
varchar(200)`, `travel_start_date date`, `travel_end_date date`.
- `PATCH /api/reimbursements/:id` accepts nullable camel-case counterparts.
- Detail response includes `travelSummary: { isTravel, purpose, location,
startDate, endDate }`.
- A privileged correction of an already submitted reimbursement writes an
`AuditLog` in the same transaction. Its `extra.changes` is an object keyed by
changed field name, with `{ before, after }` scalar snapshots.
### 3. Contracts
- Only `draft`/`rejected` applicant-editable reimbursements may change the four
fields. Reviewing records are immutable business facts.
- Linked travel requests seed purpose, ordered segment cities and dates. Blank
reimbursements persist user input directly.
- Historical read-only travel records may display description as purpose and
min/max travel-item dates; location remains `未填写` and is never guessed.
- Submit requires all four fields only for travel reimbursements; ordinary
reimbursements remain unaffected.
- An administrator correcting an already submitted amount, expense date,
travel-summary date or other core declared financial fact must record the
exact normalized `before` and `after` values in the restricted database audit
entry. Dates use `YYYY-MM-DD`; decimal amounts use canonical strings. The
audit also records a sanitized reason and operation/task reference.
- The repository-visible Trellis task, docs and generated evidence record only
changed field names, invariant results and counts. They must not copy the
real purpose, location, amount, ticket content or audit payload.
- Do not put attachment bytes, OCR/provider payloads or unrelated personal data
into `extra.changes`. Existing `system.audit.view` authorization remains the
access boundary for the database audit.
### 4. Validation & Error Matrix
- String over 200 chars or malformed date -> `400 VALID_001`.
- Start after end -> `400` with `travelEndDate: invalid_date_range`.
- Travel submit with missing fields -> `400` with
`travelSummary: incomplete` and stable `missingFields`.
- Edit while not draft/rejected -> existing `409 invalid_edit_status`.
- Privileged correction changes a core declared fact but lacks a field-level
`before` or `after` snapshot -> abort the transaction; a field-name-only
audit is insufficient.
- A historical correction is later found without its original snapshot -> do
not invent or reconstruct an audit value from memory. Record the evidence
gap and enforce the complete audit contract on future corrections.
### 5. Good/Base/Bad Cases
- Good: travel item plus complete purpose/location/date range submits normally.
- Base: office-supply reimbursement has no travel summary and is not blocked.
- Good: an authorized date correction atomically updates the row and writes
`extra.changes.expenseDate = { before, after }` using synthetic or real
database values visible only to authorized auditors.
- Bad: infer a destination from invoice OCR, attachment name or free text.
- Bad: overwrite a submitted expense date and audit only
`changedFields: ["expenseDate"]`, leaving the original fact unrecoverable.
### 6. Tests Required
- Prisma validation and all migrations against an empty PostgreSQL database.
- Route tests for detail fallback, complete/incomplete travel submit, date order
and non-travel compatibility; frontend typecheck/build and PC/mobile UI tests.
- Privileged-correction tests use synthetic data and assert one transaction,
exact normalized `before/after`, sanitized reason, unchanged amount/material/
workflow invariants, rollback on audit failure and no real values in generated
repository evidence.
### 7. Wrong vs Correct
#### Wrong
```js
travelLocation = invoice.ocrResult.sellerName;
```
```js
extra = { changedFields: ["expenseDate"] }; // original declared fact is lost
```
#### Correct
```js
travelLocation = reimbursement.travel_location ?? null;
```
```js
extra = {
changes: {
expenseDate: { before: "2026-01-02", after: "2026-01-03" },
},
reason: "authorized_correction",
operationRef: "TASK-SYNTHETIC",
};
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger
+15 -8
查看文件
@@ -134,7 +134,10 @@
`buffer`, `extension`, `mimeType`, `fileSize` and `fileSha256`.
- Browser downloads of OA-stored attachments must use a currently
authenticated request, preferably an API-local `POST` with optional JSON
`{ "reason": "..." }`, rather than a query bearer URL.
`{ "reason": "..." }`, rather than a query bearer URL. The only approved
exception is the at-most-120-second, purpose/object/actor-bound grant used by
the official WeCom `previewFile` native layer; follow `wecom-boundary.md` and
do not reuse that grant for ordinary browser downloads or other integrations.
### 3. Contracts
@@ -163,9 +166,12 @@
- Successful download audit is part of authorization: write it before setting
attachment headers or sending bytes. Audit failure must return a non-success
response with no file body.
- Successful responses use `application/octet-stream`, `attachment`,
`nosniff`, private no-store caching, no-referrer and a route-level sandbox
CSP. Do not restore same-origin inline preview or query-token download routes.
- Successful ordinary browser downloads use `application/octet-stream`,
`attachment`, `nosniff`, private no-store caching, no-referrer and a
route-level sandbox CSP. Do not restore general same-origin inline preview or
query-token download routes. The WeCom-native reimbursement invoice/attachment
exception retains the domain-specific `inline` response and must still audit
before bytes, use no-store/no-referrer and reject expired or mismatched grants.
### 4. Validation & Error Matrix
@@ -220,10 +226,11 @@
- Frontend typecheck/build after API type changes.
- Focused UI verification for pages that add attachment governance labels or
download-reason interactions.
- Frontend download tests must assert authenticated JSON request + Blob browser
download and absence of popup/inline preview. Use only synthetic in-memory
fixtures; production attachments and historical bulk scanning require a
separate approval.
- Frontend ordinary-download tests must assert authenticated JSON request + Blob
browser download. WeCom-native preview tests instead assert the official
bridge receives the exact short URL/name/size and that `window.open` is not
used. Use only synthetic in-memory fixtures; production attachments and
historical bulk scanning require a separate approval.
### 7. Wrong vs Correct
+525 -5
查看文件
@@ -41,6 +41,207 @@ or administration workflow has already migrated.
rule, applicant-visible mobile submission page and actual post-submission
approval chain; API template metadata alone is insufficient.
## Scenario: Native File Preview With Narrow Short-Lived Grants
### 1. Scope / Trigger
- Trigger: an authenticated OA page inside the Enterprise WeChat self-built app
must preview a reimbursement invoice or supporting file with the official
`previewFile` JSAPI.
- Purpose: WeCom's native downloader does not reliably carry the WebView cookie
or OA bearer header, while ordinary PDF links may download instead of preview.
This is a narrow native-preview bridge, not a general query-token download
design.
### 2. Signatures
- JS-SDK signature: `POST /api/auth/wecom/js-sdk-signature`, authenticated body
`{ url: string }`; response `{ corpId, timestamp, nonceStr, signature }`.
- Invoice grant: `POST /api/invoice-pool/:id/signed-url`; response includes
`{ signedUrl, expiresAt, originalName, mimeType, fileSize }`.
- Reimbursement attachment grant:
`POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional
body `{ reason }`; response uses the same file metadata shape.
- Reimbursement material bundle:
`POST /api/reimbursements/:id/materials/preview-bundle`, optional body
`{ reason }`; response contains one short-lived merged PDF URL plus exact
`name`, `size` and `pageCount`.
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
and `size` is the persisted positive byte count.
### 3. Contracts
- Fetch and cache the WeCom JSAPI ticket only on the server. Never return or log
the ticket, app access token, Agent Secret or raw provider response.
- Sign only the first configured OA `APP_ORIGIN`; reject cross-origin URLs,
URL credentials and fragments. The signed page URL excludes the hash.
- A native file grant expires in at most 120 seconds and binds purpose, exact
business object, exact file object and actor. It is signed only after current
authentication, business-read permission and download-reason checks pass.
- Invoice access may reuse the existing current-pending-task exception. Do not
grant the approver invoice-management, edit, unbind or delete permission.
- The native GET writes a successful access audit before sending headers or
bytes and returns `private, no-store` plus `no-referrer`.
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
the actual file through a WeCom message or native approval payload.
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
pages are copied and images are embedded as pages, then WeCom receives one
`previewFile` call. The derivative is explicitly not an original or digital-
signature fact source; individual originals remain separately authorized.
- JS-SDK is allowed only on authenticated business pages that need a native
capability and is dynamically imported behind WeCom detection. The hosted PC
login/password/email-code page remains script-free as required by the
`Hosted PC WeCom Web Login` scenario below.
### 4. Validation & Error Matrix
- Missing authentication on signature/grant issue -> `401`.
- Cross-origin page URL -> `403`; malformed, credential-bearing or fragment URL
-> `400`, before any provider call.
- Ticket/token provider failure -> stable `503` detail without provider values.
- Missing file metadata, extension or positive integer size -> frontend stops
before `previewFile` and shows a stable error.
- Missing required download reason -> `400 { reason: "required" }`.
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
- Access audit failure -> `5xx` with no file body.
- Missing/invalid merged PDF URL, name or size -> frontend stops before the
bridge call. Bundle expiry/cache loss -> safe fallback to individually
authorized originals; do not broaden token scope or extend it silently.
### 5. Good/Base/Bad Cases
- Good: a pending reimbursement approver opens a 120-second invoice URL through
`previewFile`, sees the original in WeCom and receives no edit capability.
- Base: a PC user opens the same invoice through the browser and downloads an
attachment through the existing authenticated Blob path.
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
then `previewFile` opens it for continuous native scrolling.
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
put a provider ticket in the response, or use async `window.open(blob:)` in
WeCom and assume desktop popup behavior applies.
### 6. Tests Required
- Route tests cover same-origin signing, provider failure/redaction and rejection
of cross-origin, credential-bearing and fragment URLs before provider access.
- Grant tests assert exact object/actor binding, at-most-120-second expiry,
metadata, reason gate, issue audit, access audit, tamper/expiry/mismatch denial
and audit-failure fail-closed behavior using synthetic files only.
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
the exact URL/name/size for invoice and attachment, and asserts no
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
filename and byte size. Backend tests separately prove deterministic output,
exact response size and object/actor/bundle binding.
- Production deployment and real-device preview remain separate approvals.
### 7. Wrong vs Correct
#### Wrong
```text
await downloadWithBearer();
window.open(URL.createObjectURL(blob)); // WeCom may block after await
```
#### Correct
```text
authenticated grant issue -> <=120s object-bound URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before file bytes
authenticated material-bundle issue -> one <=120s deterministic PDF URL
-> ww.previewFile({ url, nameWithExtension, exactSize })
-> audit before exact cached PDF bytes
```
## Scenario: WeCom Member Data Is A Read-Only Source For OA
### 1. Scope / Trigger
- Trigger: importing directory members, changing OA employee/profile or manager
routes, enabling directory sync, or adding any WeCom member create/update/disable
call.
- Purpose: keep WeCom as the member-account fact source and prevent an ordinary
OA account-management action from unexpectedly changing the real WeCom directory.
### 2. Signatures
- Approved inbound CLI: `npm run import:wecom-directory-export -- --input=-
--dry-run|--apply`; apply retains its existing dual gate and writes OA only.
- OA workflow manager field: `Employee.direct_manager_id`; it is not the WeCom
directory supervisor field.
- Outbound safety gates: `WECOM_CONTACT_SYNC_ENABLED=false` and effective feature
flag `hr_wecom_directory_sync_v1=force_off` for formal internal operation.
- Existing outbound API family `/cgi-bin/user/create|update` and disable payloads
remain unavailable to ordinary OA user/manager maintenance.
### 3. Contracts
- A directory export or approved read API may create/update the minimum OA
employee mirror and `WecomIdentityBinding`; it must not create, update or
disable the WeCom member.
- WeCom name, department, supervisor, phone, email and active state are edited by
the member in an allowed WeCom surface or by a WeCom administrator. OA learns
later changes through another approved inbound read/import.
- Setting `direct_manager_id` changes only OA workflow routing. It does not imply
or trigger a WeCom supervisor update.
- An OA account route may record a skipped directory-sync run while the outbound
gate is disabled, but it must stop before token acquisition or an external
member-write request. The skipped state is evidence of zero external writes.
- Any future reverse member sync requires a separate Trellis task, per-field
side-effect design, dry-run, explicit production approval and rollback. It may
not be introduced as an implicit side effect of an existing OA route.
### 4. Validation & Error Matrix
- Import input conflicts or is ambiguous -> block the OA write; never merge or
repair the WeCom member automatically.
- OA manager/profile action with outbound gate disabled -> OA transaction may
succeed, outbound sync is `skipped`, zero WeCom access-token/member-write calls.
- Effective outbound flag unexpectedly enabled during ordinary internal operation
-> deployment/operation finding; do not exercise the write path to diagnose it.
- A requested WeCom profile change -> direct the member or WeCom administrator to
WeCom; do not patch the OA mirror and push it outward.
### 5. Good/Base/Bad Cases
- Good: administrator exports one existing member, OA imports one `employee`
account and identity binding, then sets an OA-only manager while outbound sync
remains skipped.
- Base: a member later changes phone/email in WeCom; OA keeps the previous mirror
until the next approved inbound refresh.
- Bad: `POST /api/users/batch/set-manager` silently calls `/cgi-bin/user/update`,
or OA profile editing becomes a back door for changing the WeCom directory.
### 6. Tests Required
- Directory-export tests prove stdin-only parsing, dual apply gate, employee-only
role and no outbound client invocation.
- User manager/profile route tests assert an effective disabled outbound gate
produces `skipped` before token acquisition/fetch while preserving OA audit.
- Env/feature-contract checks keep the formal default off and reject a production
claim that relies on an unapproved reverse-sync flag.
- A separately proposed reverse-sync task must add mocked API payload assertions,
negative permission/conflict cases and explicit live approval evidence.
### 7. Wrong vs Correct
#### Wrong
```text
OA set-manager/profile update -> WeCom /user/update -> real member changed
```
#### Correct
```text
WeCom member/admin edits source data -> approved inbound read/import -> OA mirror
OA direct_manager_id update -> OA workflow only; outbound member sync skipped
```
## Scenario: Compensatory Leave Is A WeCom Leave Category
### 1. Scope / Trigger
@@ -168,9 +369,10 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
- `login|bind` always keeps `snsapi_base`; only explicit `onboard` uses
`snsapi_privateinfo`.
- The member must be visible to the app, active, return the same userid from
both identity calls, and authorize a valid phone plus personal email or
business email. A blank/invalid personal email may fall back to a valid
business email.
both identity calls, and authorize a valid personal email or business email.
Phone may be absent; when returned it must be normalized and checked for
uniqueness. A blank/invalid personal email may fall back to a valid business
email.
- `user_ticket`, OAuth code, access token and raw provider responses exist only
in request memory. Do not persist them or include them in Cookie, audit,
response details or logs; provider/network failures use fixed safe codes.
@@ -190,8 +392,9 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
- Provider/network failure -> `wecom_api_failed` or safe auth error; no raw
payload, token or ticket in output/logs.
- Userid mismatch -> `identity_mismatch`; inactive member -> `member_inactive`.
- Missing/invalid phone, both emails invalid, or unresolved department ->
`missing_required_field`.
- Both emails invalid or unresolved department -> `missing_required_field`.
- Phone absent -> continue with `NULL`; phone present but conflicting ->
`employee_conflict`.
- Multiple employees, unique-field race or cross-binding -> `employee_conflict`.
- Department binding points to missing/archived data -> `department_invalid`;
multiple same-name OA departments -> `department_ambiguous`.
@@ -232,3 +435,320 @@ ordinary login -> snsapi_base
one-time onboard -> explicit private consent -> safe unique employee-only write
business role assignment -> separate administrator-audited operation
```
## Scenario: Passwordless First WeCom Login For Existing OA Accounts
### 1. Scope / Trigger
- Trigger: changing ordinary `mode=login`, identity binding, directory-email
matching, the password login fallback or account-activation readiness.
- Purpose: every existing active OA employee must be able to enter from the
WeCom workbench on the first attempt without entering an OA password, while
ambiguous identities remain fail-closed.
### 2. Signatures
- Entry: `GET /api/auth/wecom/login?returnTo=<internal-path>` with
`snsapi_base`.
- OAuth callback identity: internal `userid` from `/cgi-bin/auth/getuserinfo`.
- Unbound member profile: `GET /cgi-bin/user/get?userid=<oauth-userid>` using
the in-memory app access token.
- Binding DB: unique `WecomIdentityBinding.employee_id` and
`WecomIdentityBinding.external_user_id`.
- Audit actions: `AUTH_WECOM_AUTO_BOUND` followed by
`AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource` and the
fixed allowlisted `entrySource`.
### 3. Contracts
- Existing active binding is the fast path and does not call `/user/get`.
- Without a binding, exact OA `employee_no == userid` is a valid stable match.
A normalized WeCom `biz_mail`/`email` may also match one existing active OA
`Employee.email` using case-insensitive exact equality. If userid and email
point to different employees, stop.
- Transactionally recheck both unique binding directions before create. A
concurrent unique-constraint race is a conflict, not permission to overwrite.
- Ordinary login never creates an employee, updates roles, writes the WeCom
directory, matches by name/phone, or consumes a pending cookie during OA
password login.
- Access tokens, full userid, enterprise email and raw provider payload stay in
request memory. Auto-bind audit contains only `matchBy` and
`emailMatched`; errors and redirects use stable categories.
- Missing enterprise email does not block an exact userid/employee-number
match. Without either stable match, no session or binding is created.
### 4. Validation & Error Matrix
- Existing binding + active employee -> direct session.
- Existing binding + inactive employee -> `account_mapping_inactive`.
- `/user/get` network/API failure or no OA match ->
`account_mapping_required`; no write.
- WeCom member inactive or matched OA employee inactive ->
`account_mapping_inactive`; no write.
- Userid/email point to different employees, either side already has a
different binding, identity mismatch or binding race ->
`account_mapping_conflict`; no overwrite.
- Password login with a legacy `wecom_login_bind` cookie -> normal password
login only, clear the cookie, return `wecomBindingStatus=null`.
### 5. Good/Base/Bad Cases
- Good: an unbound active member's enterprise email matches exactly one active
OA employee; callback creates one binding and audit, then redirects into the
requested OA page without a password.
- Base: a previously bound member uses the existing fast path and creates only
the normal WeCom login session/audit.
- Bad: bind by display name, overwrite another userid, create a new employee,
or ask the member to enter an OA password to complete first binding.
### 6. Tests Required
- Service tests: profile userid equality, member state, business-email
normalization, userid-only match, email-only match, split-key conflict,
inactive employee, cross-binding and redacted audit.
- Route tests: existing binding fast path, passwordless auto-bind + session,
provider-unavailable fail-closed redirect and no pending binding cookie.
- Password-route regression: a legacy pending cookie cannot create/update a
binding and is cleared.
- Frontend typecheck/build and focused WeCom notice tests; full backend suite,
Prisma validation, sensitive-content and Trellis governance gates.
### 7. Wrong vs Correct
#### Wrong
```text
unbound OAuth userid -> password login -> consume pending cookie -> bind
```
#### Correct
```text
OAuth userid -> existing binding OR exact userid/unique enterprise email match
-> transactional binding -> redacted audit -> direct WeCom session
```
## Scenario: Hosted PC WeCom Web Login
### 1. Scope / Trigger
- Trigger: adding or changing ordinary-browser WeCom login, the public WeCom
config response, OAuth state/callback handling or login-entry audit.
- Purpose: let an existing OA employee use the WeCom-hosted Web login page from
a normal PC browser without loading third-party scripts beside OA password or
email-code inputs.
### 2. Signatures
- Public config: `GET /api/auth/wecom/config` returns
`pcWebLoginEnabled: boolean` and `pcLoginUrl: string | null` separately from
the workbench `loginUrl`.
- OA entry: `GET /api/auth/wecom/login?returnTo=<internal-path>&entrySource=pc_qr`.
- Provider entry: `https://login.work.weixin.qq.com/wwlogin/sso/login` with
`login_type=CorpApp`, CorpID, AgentID, callback and signed state.
- Shared callback: `GET /api/auth/wecom/callback?code=<one-time-code>&state=<signed-state>`.
- Env gate: `WECOM_PC_WEB_LOGIN_ENABLED=false` by default.
- Audit source: `entrySource=pc_qr | wecom_workbench`; `mode` remains
`login | bind | onboard`.
### 3. Contracts
- The OA login page uses a normal redirect to the WeCom-hosted page. Do not add
`@wecom/jssdk`, a WeCom CDN script or a QR iframe to the credential page.
- `entrySource` is independent of `mode`, is validated against a fixed server
allowlist and never accepts `pc_qr` for bind/onboard. Missing source in an
in-flight legacy state means `wecom_workbench`; an unknown signed value fails.
- PC state remains a short-lived HMAC-signed payload and is correlated to the
initiating browser with a short-lived HttpOnly, Secure-in-production,
SameSite=Lax cookie. Validate and clear it before code exchange.
- The cookie is a CSRF/browser-correlation control, not server-side nonce
storage. Normal-browser replay fails after cookie clearing; deliberate replay
of both URL and old cookie is finally stopped by WeCom's one-time, five-minute
code contract and must not create a second OA session.
- Reuse existing userid mapping, fail-closed auto-binding, session limit,
device fingerprint, returnTo allowlist and structured mapping notices.
- Successful login audit contains only `bindingSource` and `entrySource`.
Code, state, nonce, cookies, full userid, Secret and raw provider payload stay
out of logs, audit and browser error details.
### 4. Validation & Error Matrix
- PC env gate off or base entry config incomplete -> `pcLoginUrl=null`; direct
PC entry is unavailable while workbench/password/email-code behavior remains.
- `pc_qr + bind/onboard` or unknown query source -> validation failure before
redirecting to WeCom.
- Tampered/expired state, unknown callback source, missing/mismatched/expired
correlation cookie or missing code -> fail before provider code exchange.
- Signed PC state after the PC gate is turned off -> clear the cookie and fail.
- Existing binding/unique auto-match -> shared session flow; mapping required,
conflict or inactive -> existing stable notice and no session.
- Replayed URL plus old cookie -> provider rejects the already-consumed code;
OA session and success audit counts remain unchanged.
- External/absolute/protocol-relative returnTo -> safe internal default.
### 5. Good/Base/Bad Cases
- Good: normal PC browser redirects to the hosted page, scans, returns with a
correlated callback and reaches the requested internal OA page.
- Base: WeCom workbench continues through `snsapi_base` with
`entrySource=wecom_workbench`; missing source remains compatible during a
rolling deployment.
- Bad: add `mode=qr`, load an SDK next to password fields, expose Secret in the
frontend, accept callback without browser correlation, or claim a signed
cookie is a server-consumed nonce.
### 6. Tests Required
- Route tests assert config gate, exact hosted host/path and parameters, signed
source/state, secure cookie attributes and PC bind/onboard rejection.
- Callback tests assert missing/mismatched/expired cookie, expired/tampered
state, missing code, unknown source and gate-off failures occur before fetch.
- Success/replay tests assert shared mapping and session behavior, sanitized
`entrySource`, cookie clearing and exactly one created session.
- Frontend E2E asserts PC-only button, safe returnTo/device fingerprint and no
duplicate button in a WeCom user agent; typecheck and production build pass.
- Run full backend tests, Prisma validation, external/readiness contracts,
sensitive-content scan, process/Trellis gates and `git diff --check`.
### 7. Wrong vs Correct
#### Wrong
```text
normal browser -> mode=qr -> third-party SDK on OA credential page
-> callback trusts signed state alone -> audit raw userid
```
#### Correct
```text
normal browser -> entrySource=pc_qr -> OA sets correlation cookie
-> WeCom-hosted login -> shared callback validates state + cookie
-> one-time code -> existing userid mapping -> sanitized session audit
```
## Scenario: WeCom Directory Export With Optional Phone
### 1. Scope / Trigger
- Trigger: importing an administrator-exported WeCom directory XLSX, or
changing `Employee.phone` across database, API, HR sync, OAuth and UI layers.
- Purpose: allow a real member to remain temporarily without a phone while
preserving stable identity, uniqueness and privacy controls.
### 2. Signatures
- DB: `Employee.phone String? @unique`; migration drops only the `NOT NULL`
constraint and keeps the unique index.
- CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`.
- Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply
--actor-employee-no=<employee-no>`.
- When apply runs through Compose v1, pass the confirmation variable into the
container explicitly: `docker-compose exec -T -e
WECOM_DIRECTORY_EXPORT_APPLY server ...`; exporting it only in the host shell
does not satisfy the in-container gate.
- Binary stdin reader: `fs.readFileSync(0)`; do not assume
`fs.promises.readFile(0)` accepts a numeric file descriptor on the production
Node runtime.
- User API and frontend employee/user types expose `phone: string | null`;
create/update accepts an omitted or explicit `null` phone.
### 3. Contracts
- Missing phone is stored as SQL `NULL`, never `""`, a repeated placeholder or
a synthesized fake number. Multiple `NULL` rows are valid; non-null phones
remain unique.
- WeCom account and valid enterprise email remain required for directory-export
onboarding. Account, non-null phone and enterprise email duplicates block
the input.
- An empty source phone never clears an existing OA phone. Only an explicitly
supplied valid phone participates in update and unique-conflict checks.
- The XLSX is accepted only from stdin and defaults to dry-run. Apply requires
both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor.
- Read the XLSX directly from fd 0 without a temporary file. A runtime that
rejects the stdin read must stop before Prisma sync; do not fall back to
persisting the workbook on the host or in the container.
- CLI output contains counts and categories only. The workbook, full local path,
names, accounts, phones, emails, departments and raw rows must not enter Git,
Trellis artifacts or reports.
- New accounts receive only `employee`; finance, CEO and admin roles remain a
separate administrator-audited operation.
### 4. Validation & Error Matrix
- Missing phone cell -> valid profile with `phone=null`.
- Supplied malformed phone -> incomplete/blocked; do not downgrade it to an
absent phone and do not write it.
- Missing/invalid enterprise email, account, name or department -> skipped or
blocked as `missing_required_field`.
- Duplicate account/non-null phone/enterprise email in the workbook ->
`duplicate_values`; error output contains counts only.
- Existing OA unique-field or binding conflict -> conflict; no automatic merge.
- `--apply` without confirmation env or active actor -> refuse before sync.
- Confirmation env exported only on the Compose host ->
`apply_confirmation_required`; no sync/write. Add the name-only `-e` inherit
flag and rerun the same approved input.
- Promise-based stdin fd read returns `ERR_INVALID_ARG_TYPE` on the deployed
Node runtime -> no sync/write; use the fd-compatible reader and rerun dry-run
before requesting apply approval.
- Migration rollback with any `phone IS NULL` row -> stop; do not fill fake
values merely to restore `NOT NULL`.
### 5. Good/Base/Bad Cases
- Good: five members have unique phones, one member has no phone, all six have
unique accounts and enterprise emails; dry-run plans six records and reports
only aggregate counts.
- Base: a later export still omits a member phone; the member's existing OA
phone is preserved.
- Base: the local and production Node versions differ; the same binary stdin
workbook parses through `fs.readFileSync(0)` without a temporary file.
- Base: Compose v1 inherits the already-set non-secret apply confirmation only
when `exec -e WECOM_DIRECTORY_EXPORT_APPLY` is present.
- Bad: write an empty string or shared placeholder, use personal email in place
of the required enterprise email for bulk identity, or auto-grant a business
role during import.
### 6. Tests Required
- Prisma validation plus a migration review proving only `DROP NOT NULL`.
- Unit tests for instruction rows/Chinese headers, five-present/one-missing,
malformed phone, missing enterprise email and duplicate account/phone/email.
- Unit regression asserts the CLI stdin helper calls `fs.readFileSync(0)`;
production dry-run must prove aggregate parsing/sync counts before apply.
- Apply acceptance must include a negative preflight without the container env
gate and a successful approved run with the name-only Compose `-e` flag;
both outputs remain count/category only.
- Route/service tests for create/update/import `null`, non-empty unique checks,
OAuth missing phone and empty-source preservation of an existing phone.
- Frontend typecheck/build and displays that render a clear empty-phone label.
- Full backend regression, Trellis governance, sensitive-content scan and
`git diff --check`; a real workbook may be parsed only into aggregate counts
and remains Git-ignored.
### 7. Wrong vs Correct
#### Wrong
```js
phone: source.phone || "00000000000"
```
#### Correct
```js
phone: source.phone || null
// Existing employee updates omit `phone` entirely when the source is empty.
```
#### Wrong
```js
const workbook = await fs.promises.readFile(0);
```
#### Correct
```js
const workbook = fs.readFileSync(0);
```
@@ -0,0 +1,326 @@
# 杨俊报销就绪:技术设计
## 1. 设计目标
以最小生产变更打通“杨俊企微登录 → 杨俊提交真实报销 → 马建勇审批 → 王芝玲财务处理”,并在真实员工使用前由两个既有企微测试成员完成一次端到端体验验收。本任务不新增产品能力,复用现有通讯录导入、用户/角色管理、报销工作流和异机备份能力。
## 2. 已确认边界
- 企业微信成员与 OA 员工是两个独立事实:杨俊已在企微通讯录,但生产尚无 OA 员工记录和企微绑定。
- 单人通讯录输入可唯一筛出杨俊;手机号为空符合现有 nullable 合同,企业邮箱存在,`incomplete=0`。
- 生产已有 37 个 active 费用类型和 1 个 active 报销工作流定义。
- active 法体为 0,但当前报销创建、提交和费用控制不以法体为硬门禁,本任务不创建法体。
- 杨俊的真实直属主管由用户确认是马建勇。
- 自动备份 daily/watchdog/drill-reminder 与 WARCH3 service 的 `BUILD_SHA` 均已漂移,必须先修复。
- 通讯录中已有两个状态为已激活的专用企微测试成员,分别命名为“测试员工”和“测试主管”;生产 OA 尚未导入它们,因此不存在可直接登录的测试 OA 账号。
## 3. 数据流与执行门禁
```text
现场只读复核活动构建与 4 个 unit
→ 重装 automatic-backup 三项 timer/service 与 WARCH3 timer
→ 验证 unit BUILD_SHA、enabled、active
→ 手工触发一份真实自动异机备份
→ 验证源端成功与 ARM 恢复点完整/新鲜
→ 从管理员导出通讯录在内存中仅筛选杨俊
→ production dry-run(create=1、conflict/skip/fail=0)
→ production apply(初始角色仅 employee)
→ 验证 OA 员工 active、企微绑定唯一、其他成员未导入
→ 通过现有用户管理接口设置 direct_manager_id=马建勇
→ 验证主管 active、不同人、具备 expense.approve,并核对审计
→ 从同一通讯录仅筛选两个既有测试成员并 dry-run/apply
→ 赋予最小角色并设置“测试员工 → 测试主管”的 OA 主管关系
→ 两个测试成员通过企微分别登录
→ 创建一条受控测试报销并由测试主管退回
→ 删除测试报销、归档测试票据、停用测试 OA 账号并撤销会话
→ 杨俊本人企微登录并提交真实报销
→ 马建勇审批,随后进入王芝玲财务处理链路
```
任一门禁失败即停止后续步骤,不用后续成功掩盖前序失败。
## 4. 复用合同
### 4.1 通讯录导入
- 复用 `server/src/scripts/import-wecom-directory-export.js` 的 stdin 输入,不把筛选后的 XLSX 或成员原始 payload 写入磁盘、日志、Trellis 或聊天。
- dry-run 只记录人数与结果计数。
- apply 必须同时满足脚本 `--apply` 参数和容器内 `WECOM_DIRECTORY_EXPORT_APPLY=1`。当前 Compose v1 调用必须显式使用 `docker-compose exec -T -e WECOM_DIRECTORY_EXPORT_APPLY ...`,不能只在宿主 shell export。
- 输入严格只有杨俊一人;成功创建后的初始角色集合严格为 `employee`,不导入其他通讯录成员。
- 此路径是“管理员导出企微通讯录 → OA 员工/身份绑定”的单向导入。OA 侧主管字段仅服务 OA 工作流;不调用企微成员 create/update/disable,也不把 OA 主管、姓名、部门、手机或邮箱写回企微。
- 企微成员资料如需变化,由成员本人在企微允许的入口修改,或由企微管理员在管理后台修改;OA 后续只通过重新导出/受控同步读取变化。
### 4.2 直属主管
- 复用 `POST /api/users/batch/set-manager`,不直写数据库。
- 写入目标仅杨俊,主管仅马建勇;接口负责 active、自指等现有校验。
- 写后核对 `SYSTEM_USERS_BATCH_SET_MANAGER` 审计增量,不记录完整账号或原始请求体。
- 马建勇必须是 active、与杨俊不同且按有效角色解析出 `expense.approve`。
### 4.3 报销业务
- 杨俊无需先存在差旅申请;`linked_travel_request_id` 可空。
- 提交仍须满足既有硬门禁:至少一条费用明细、至少一张已绑定且状态为 `bound` 的真实票据、可匹配工作流并能解析审批人。
- 第一笔真实报销必须由杨俊本人操作;Codex 不代建生产报销、发票、付款或测试业务数据。
### 4.4 timer 与备份
- 使用现有 `install-automatic-backup-timer.sh` 和 `install-warch3-ops-timer.sh`,从活动部署现场解析并显式传入合同参数;不在任务文档中固化 Secret、宿主路径或完整账号。
- 重装后核对 4 个 service 的 `BUILD_SHA` 与活动 server 一致,并确认对应 timers 为 enabled/active。
- 随后手工触发 daily 自动备份路径,验证源端 service 成功、ARM 批次完成且 dead-man/新鲜度检查通过。
- 新鲜异机恢复点是人员数据写入的前置条件,但不会用于回滚单个人员变更。
### 4.5 两个既有测试成员
- 不创建新的 OA-only 测试身份,也不调用企微成员创建接口;复用管理员导出表中的两个专用企微测试成员,通过通讯录导入建立 OA 员工与企微身份绑定。
- 导入输入只含“测试员工”和“测试主管”,手机号、邮箱、企微 userid 等原始字段不进入任务证据、聊天或日志。
- “测试员工”角色集合严格为 `employee`;“测试主管”角色集合严格为 `employee + manager`。后者只用于验证 `expense.approve` 和主管待办,不授予财务、薪资、银行资料或系统管理权限。
- 通过现有主管接口把测试员工的 `direct_manager_id` 指向测试主管;所有 OA 主管与角色写入均保留现有审计,不回写企微。
- 两个账号必须分别从企微入口登录,以同时验证通讯录导入后的身份绑定和实际页面权限;无需生成或传递临时密码。
### 4.6 受控测试报销生命周期
```text
测试员工创建报销
→ 使用 0.01 元最小测试值和“系统测试、非真实业务、禁止付款”描述
→ 上传不含真实票据/个人资料的安全 PDF
→ OCR 成功则继续;OCR 失败则在票据池人工填入唯一测试字段并确认可用
→ 绑定测试票据并提交
→ 测试主管确认待办可见并执行“退回”
→ 报销恢复 draft
→ 测试员工删除 draft(票据释放为 available)
→ 测试员工归档已释放票据
→ 管理员停用两个测试 OA 账号并核对会话撤销
```
- 测试主管不得点击“通过”;这保证测试单不会进入真实财务列表或付款批次。
- 删除仅允许发生在退回后的 `draft`;`POST /api/workflow/tasks/:taskId/return` 会在同一事务中把报销明确改回 `draft` 并终止 workflow instance,因此无需再执行申请人撤回。现有草稿删除接口会把已绑定票据释放为 `available`。
- 票据仅在解绑且为 `available`/`ocr_failed` 时手工归档;归档后保留最小审计痕迹但不再是可用业务票据。
- 账号收口使用现有 OA 停用接口,递增 token version 并撤销活动会话;企微侧两个测试成员保持原状。
### 4.7 受控测试发现与真实使用门禁
- 测试实际确认:生产当前使用 `OCR_PROVIDER=mock`,未配置腾讯 OCR 凭证;上传的合成 PDF 被写入明显的 Mock 发票号码、销售方、日期和金额并标记为可用。
- mock 只能作为合成测试依赖,不能让真实员工把其结果误认为真实识别。杨俊真实使用前必须二选一并再次复验:配置腾讯 OCR;或关闭 mock,明确采用人工录入/核对流程。
- 用户选择并批准关闭 mock。现场拓扑没有独立 worker,`JOBS_ENABLED=true`,因此只重建实际承载 Web 与后台任务的 server;不得为了“同时重启 worker”而凭空创建第二个作业执行器。
- `OCR_PROVIDER=off` 复验确认:新 PDF 直接进入 `available`,发票代码、号码、日期、购销方和金额字段均为空,OCR 尝试为 0、请求/完成时间为空;票据可以绑定到单明细 0.01 元测试报销并正常提交、退回和清理。
- `InvoicePool.ocr_provider` 是非空枚举且 schema 默认 `tencent`;off 路径传入 `undefined` 后数据库仍写入该默认值。该字段只是历史 schema 默认,不代表发生过 OCR 调用;当前 UI 不展示 provider。是否在后续迁移中改为 nullable 应与腾讯 OCR/人工录入产品决策一起规划,不阻断本次真实报销。
- `InvoicePool` 当前使用 `@@unique([file_sha256, ocr_provider, ocr_type])`。后续若把 provider 改成 nullable 或新增 `off` 枚举,必须同步回填历史行或重设计唯一键;只改新行 provider 会把同一文件拆进两个查重命名空间。生产只读核查显示历史 `mock` 票据共 1 张且仅由测试账号上传,非测试员工上传数为 0。
- off 模式下发票号为空,基于 `invoice_code + invoice_number` 的查重不可用,但文件 SHA-256 查重仍生效;当前小团队短期由财务查看 PDF 原件并人工核对,不在本任务增加强制补录。
- 草稿删除成功后,详情页在导航完成前仍短暂请求已删除的详情与 precheck,浏览器产生预期 404 控制台噪声,但用户已回到空草稿列表。这是非阻断前端时序 finding,不影响数据清理结果。
## 5. 失败与回退
- timer 重装或异机备份失败:不写杨俊数据,保留本地诊断并走既有脱敏群告警。
- dry-run 不满足 `target=1 / active=1 / incomplete=0 / planned create=1 / conflicts=0 / skipped=0 / failed=0`:不 apply,重新核对输入或生产现状。
- apply 失败:停止;按脚本结果诊断,不手工拼接残缺账号。
- apply 成功但主管写入失败:保留已创建的 OA 员工和企微绑定,不删除、不恢复整库;主管仍为空时不能提交报销,可安全重试同一主管接口。
- 主管写错:通过同一接口恢复写入前的 `direct_manager_id`,不恢复整库。
- 真实报销提交失败:保留草稿和真实票据状态,按返回的业务错误定位;不得通过扩权、代批或伪造数据绕过。
- 测试导入或角色/主管设置失败:停止测试,不借用真实员工账号;按单步接口恢复两名测试成员原 OA 状态。
- 测试提交失败:保留 draft 和测试票据定位问题;不得改成“审批通过”绕过。修复后从同一测试草稿重试或删除后重建。
- 测试退回后删除失败:禁止停用账号,先保留两名测试账号以完成清理;不得直写数据库删除业务记录。
- 测试账号误被赋予超范围角色:立即通过现有角色接口恢复最小角色集合并撤销会话,不恢复整库。
- OCR 仍为 mock:保持测试账号停用,不让杨俊上传真实票据;不得用“人工记住忽略 Mock 字段”作为默认生产口径。当前已切换为 off,此回退项不再触发。
## 6. 隐私与审计
- 终端与任务证据只保留布尔值、计数、角色 slug、审计 action 和脱敏状态。
- 不输出手机号、企业邮箱、企微 userid、完整账号、通讯录原始行、票据内容、银行资料、Secret 或完整宿主路径。
- 账号导入与主管写入分别保留现有系统审计,确保每一步可独立追踪和撤销。
## 7. 不做的设计扩展
- 不新增导入 API、主管兜底规则、CEO 自审批能力或报销状态。
- 不启用 OA → 企微通讯录写回,不修改任何企微成员资料。
- 不创建法体、不补银行账号、不导入其余成员。
- 不把首次真实业务扩成管理员代办的生产自动化测试。
- 不创建新的企微测试成员,不给测试主管财务或管理员权限,不让测试单通过审批或进入付款链路。
## 8. 真实首跑补充风险复核
- 生产三名真实参与人均 active 且企微绑定唯一,但尚无 `AUTH_WECOM_LOGIN_SUCCESS` 记录;企微客户端入口仍需本人首登验证。
- 当前 active 费用标准为 0、杨俊部门适用预算池为 0;真实金额不会触发费用标准或预算阻断。active 法体为 0、杨俊未绑定法体,但现有报销提交和付款批次候选不以法体为硬门禁。
- 金额审批规则不是同一条:`[0,500)` 为 `manager → finance`,`[500,5000)` 为 `manager → finance_mgr`,`[5000,+∞)` 为 `manager → ceo → finance_mgr`。
- 当前 active 角色持有人:`finance=1`、`ceo=1`、`finance_mgr=0`。因此低于 500 元可路由到马建勇后再到王芝玲;达到 500 元的单据会生成无人可处理的 `finance_mgr` 角色待办。修复应调整审批规则到既有最小角色,不给王芝玲扩为权限过宽的 `finance_mgr`。
- 用户确认当前公司人数少且由马建勇统一负责,不需要金额分级。生产规则收敛为唯一 `[0,+∞)` 规则:`department_manager → finance`;删除另外两档,不保留 CEO 重复节点。
- 后续只有出现第二名实际审批主管,或有人基于真实职责承担 `finance_mgr`,并且业务证明需要金额升级时才重开分级设计。大额关注使用风险标记或通知,不通过同一自然人重复审批实现。
- 生产变更复用 `PATCH /api/approval-rules/:id`:先把低档规则扩为 `[0,+∞)`,再停用中、高两档,避免出现无人可匹配的短暂金额空窗;每步保留 `APPROVAL_RULE_UPDATED` 审计,失败则通过同一接口补偿恢复原三档。
- 最终审批只把报销置为 `approved`,不会自动创建付款批次或付款记录;付款批次仍由王芝玲主动创建并由马建勇复核。
## 9. 审批任务到完整报销单的可达性
- 根因不在待办列表卡片:移动待办列表已经通过 `handleTaskSelection` 进入 `/mobile/approvals/:taskId`。真实阻断发生在审批任务详情,其报销摘要只有单号、申请人和金额,没有费用明细、发票和附件,也没有进入既有报销详情的出口。
- 移动端只在审批任务详情的报销摘要区增加明确的“查看完整报销单”入口,目标复用 `/mobile/reimbursements/:reimbursementId`;导航继续使用当前移动页面通用的 `{ state: { from: currentMobileSource } }` 约定,不新增 `returnTo` 参数或另一套回跳状态。
- PC 端在 `WorkflowTaskDetailPage` 的报销摘要区提供“查看报销单”入口,目标复用 `/reimbursements/:reimbursementId`。
- 现有 `manager`、`ceo` 和 `finance` 角色已经包含 `expense.view_all`,因此本轮不调整角色、权限矩阵、后端报销详情鉴权或 workflow 数据结构。
- 不把整个摘要卡片改造成无提示的隐式链接;使用明确命名的链接按钮,避免与待办列表卡片的既有任务选择交互混淆。
- 请假、采购、差旅审批详情存在同类业务详情缺口,但本轮只修复真实报销链;其他类型另轮统一处理。
## 10. 报销详情时区与 KISS 收敛
- 后端继续用 ISO 8601 UTC 字符串传输时间,前端不得用 `slice(0, 19)` 假装本地时间。PC 报销详情、移动报销详情和 PC 审批任务详情复用 `formatDisplayDateTime`;共享函数显式使用 `Asia/Shanghai`,使公司内部 OA 不依赖浏览器所在时区。
- 不新增页面级时间工具。合成 UTC 样例通过页面文本断言验证上海时间,覆盖附件上传时间和审批任务时间。
- PC 将“当前状态与下一步”和“业务摘要”收敛为单一首屏概况,保留总额、状态、编号、日期、报销人、说明与真正可执行入口;差旅概要并入该单头,删除解释同一事实的二级标题和辅助句。
- PC 与移动只保留单头概况、费用明细、报销材料、处理进度四个可扫描标题。附件上传限制只在可编辑时出现;关联事项作为单头行、预算/风险作为就近提示、付款作为进度节点次级事实,均不形成永久空区域。
- 移动端头部已经包含状态,因此概况卡不重复状态;只保留金额、日期、报销人和说明。只读状态不显示编辑教学或空“操作”卡,编辑态仍保留新增、上传、删除和底部提交能力。
- 采用当前设计系统和现有组件,不引入新颜色、字体、动效、布局系统或后端字段;新版 `ui-ux-pro-max` 仅用于内容优先、密度和渐进披露判断。
## 11. 企微 WebView 旧包刷新护栏
- 诊断依据:生产构建的移动审批详情把报销日期和“查看完整报销单”放在同一个 `selectedTask.reimbursement` 条件块;真机截图只出现日期,说明 WebView 仍运行部署前 JS。公网 `/ready` 与当前移动 chunk 均已核对为正确构建,不把问题误判为权限、后端报销关联或列表未进入详情。
- 复用现有构建合同:前端使用 `VITE_BUILD_SHA`,公网 `/ready` 返回 server `buildSha`。页面只在 `visibilitychange` 回到 `visible` 时用 `cache: no-store` 请求 `/ready`,规范化为 12 位后比较;开发/unknown、空值、请求失败和解析失败均静默。
- 构建不一致时继续复用 `hyb-oa:pwa-update-ready` 事件及 `usePwaUpdatePrompt`,在已登录 AppShell 顶部显示一条用户控制的刷新提示;不自动刷新,避免打断审批意见、表单或其他未保存操作。
- 用户点击刷新后复用 `clearPwaRuntimeCaches()` 并重载当前 URL;同一次页面生命周期只提示一次,避免页面多次前后台切换反复提示。
- 护栏只能保护已经加载过本次新代码的页面,不能反向唤醒当前仍运行 `d2841b1` 之前旧包的 WebView;首次部署本护栏时仍需主动通知马建勇手工刷新,后续部署才由版本比对接管。
- 不新增后端 API、数据库字段、定时轮询、更新中心或强制升级状态;登录/改密页面继续沿用既有用户确认刷新逻辑。
## 9. 企业微信原生材料预览
### 9.1 运行时分流
- 企业微信 iOS/Android WebView:使用腾讯维护的 `@wecom/jssdk` 调用 `previewFile({ url, name, size })`,由企业微信原生层预览文件。
- 普通 PC 浏览器:发票使用现有短时签名 URL,附件继续使用认证下载 Blob 后由浏览器打开;两端复用同一个“查看原件”业务动作,但不强求相同底层机制。
- 非企微移动浏览器或 JS-SDK 调用失败:给出自然中文错误并保留下载/PC 查看退路;不静默宣称已预览。
### 9.2 JS-SDK 签名链
- 新增已认证的自建应用 JS-SDK 签名接口,输入为当前页面去掉 fragment 的完整 URL。
- 服务端只允许 `APP_ORIGIN` 或当前受信请求 origin,拒绝跨 origin、带凭据 URL、非 HTTP(S) 和 fragment;复用企微应用 access token,在服务端获取并缓存 JSAPI ticket。
- 返回仅包含 `corpId`、`timestamp`、`nonceStr`、`signature`;ticket、access token、Agent Secret 和企微原始响应不得返回或记录。
- 客户端仅在 `isWecomWebView()` 为真时按需注册 SDK,`jsApiList` 只包含 `previewFile`,避免把 JS-SDK 加到登录页或普通浏览器主路径。
### 9.3 文件 URL 与授权
- 发票复用 `/invoice-pool/:id/signed-url`,响应补准确 `fileSize`、`originalName` 和 `mimeType`;签发沿用票据所有者/报销参与人/当前待审批人对象级授权。
- 报销附件新增同形短时签名 URL:签发前复用 `ensureOwnOrCanViewAll`,执行治理状态与下载理由门禁;token 绑定 attachment、reimbursement、actor 和用途,TTL 取现有签名 TTL 与 120 秒中的较小值。
- 原生层取件时重新校验 token、对象关系和过期时间,实际打开前写下载/预览审计。短时 URL 是企业微信原生层无法携带 OA 认证头时的窄例外,不扩展为通用 query-token 下载机制。
- 不改角色或权限矩阵。审批人获得的只是当前报销材料只读能力,不能编辑、解绑、删除或管理票据池。
### 9.4 安全与兼容
- `name` 使用数据库原始显示名但必须确保与实际 MIME 匹配并带安全扩展名;`size` 来自持久化的准确字节数。
- 短时 URL 不写入 Trellis、聊天、前端持久化存储、错误详情或普通日志;页面关闭/刷新后重新签发。
- 生产首次验收先用无业务数据 PDF 证明 `config + previewFile`,再查看真实材料。部署、企微真机调用和真实审批继续单独获批。
## 12. 报销明细与材料的 KISS 对齐
- 交互采用“一个入口、一次通览、语义保留”:费用明细只展示费用事实,不再渲染关联发票;`linkedItemId`、票据/附件类型、对象权限、审计和生命周期均不改变。付款回单仍属于付款阶段材料,不提前混入本次提交材料。
- 报销材料首屏显示“发票 N · 补充附件 M”和一个明确的“查看材料合并预览”按钮,下方始终显示有序原件列表。每份材料只使用一个语义化整行按钮打开原件,不再叠加“查看”按钮;草稿/驳回编辑态在同一行外保留发票移除和附件删除动作,只读审批人不显示数据变更动作。
- 服务端新增报销级合并 PDF grant:授权后读取当前绑定的发票 PDF 与补充附件,PDF 原页全部复制,JPG/PNG/WebP 经 Sharp 规范化后各嵌入一页。企微调用一次 `previewFile({ url, name, size })`,PC/普通浏览器打开同一短时 PDF URL;不引入 PDF.js,不在客户端渲染 PDF。
- 预览顺序固定为发票 `created_at, id`、补充附件 `created_at, id`、PDF 内页顺序;不根据 `linkedItemId`、金额或文件名猜测排序。有序材料集合哈希覆盖材料类型、ID、文件哈希/大小/MIME,缓存键由报销单 ID、集合哈希和合并器版本组成。
- 合并在 Node `worker_threads` 中执行,不启用 Compose worker profile。worker 限制材料数、源文件总字节、PDF 总页数、图片像素、输出字节和执行时间;`pdf-lib` 不使用 `ignoreEncryption`,加密、损坏或不可复制 PDF 导致整包失败。任一材料失败都不能跳过,客户端展开现有逐份原件路径。
- 合并 PDF 固定 CreationDate、ModificationDate、Creator、Producer 和保存选项;相同有序输入必须生成完全相同字节。合并 Buffer 生成一次后缓存并原样返回,签发 `size` 取该 Buffer 长度。缓存缺失时原签发 URL 返回拒绝,不现场生成另一份字节;用户重新操作会重新签发。
- 合并件不进入业务表、不形成原件,只在有界短时缓存中保存;页面明确“报销材料合并预览,仅供审批核对;原始文件另行保留”。grant 绑定 purpose、reimbursement、actor、bundle 与过期时间,实际 GET 验签并写整包预览审计。
- 后端从已脱敏持久化 OCR `result_json` 只映射 `documentCategory` / `documentSubtype` 到报销详情,不下发原始腾讯响应。前端共享格式化函数按 `vat_invoice`、`full_vat_invoice`、`electronic_train_ticket` 选择票种名称和适用字段;未知票种使用中性“发票 + 号码/文件名”,不显示“无代码”或对不适用字段宣称“未识别”。
- “查看材料合并预览”和降级行均使用语义化 button、可见焦点、至少 44px 触控目标;超过 300ms 时按钮禁用并显示“正在准备材料预览…”。材料区提示“打开后上下滚动,连续查看全部材料”;不再提示左右滑动。
## 13. 生产验收修正:材料可读性与轻量差旅概要
### 13.1 按钮层级
- PC 现有入口已在“报销材料” `SectionPanel`,保持不变。
- 移动端将入口从 `mobile-reimbursement-items` 移到现有“报销材料” card 标题/计数之后;明细区只保留费用明细及其编辑动作。
### 13.2 合并 PDF 与确定性合同
- 生产脱敏计时基线保留为旧图片包证据;新主路径不再使用 Poppler 或图片数组。
- `createBundle` 先按显式排序取得材料并计算集合哈希,再把源字节交给受限 `worker_threads`。worker 复制全部 PDF 页、把图片嵌为页面,并返回单个 Buffer、页数和精确大小。
- 固定 PDF 元数据和序列化选项;单测对相同输入分别启动两次合并,断言 Buffer 全等。签发响应的 `fileSize` 必须等于随后 GET 的 `content-length` 和响应字节数。
- 合并缓存按报销单 ID、集合哈希、合并器版本复用;actor grant 仍独立。缓存淘汰或进程重启后旧 URL 失败,不以重新生成结果冒充旧签发字节。
- 移除旧图片 bundle 页路由与 `previewImage` 主路径;若 Poppler 无其他用途,从镜像移除 `poppler-utils`。保留单份发票/附件原件预览作为失败降级与电子签名事实源。
### 13.3 轻量差旅概要数据流
```text
草稿/驳回报销的差旅明细
-> 编辑 travelPurpose / travelLocation / travelStartDate / travelEndDate
-> PATCH 入口集中验证长度与日期顺序
-> reimbursement_forms 可空列持久化
-> 详情 API 返回轻量 travelSummary
-> PC/移动首部同一信息层级显示
-> 存在 travel category 明细时,submit precheck 拒绝缺失字段
```
- Prisma 在 `ReimbursementForm` 增加四个 nullable 列:事由、地点、开始日期、结束日期。迁移只加可空列,不回填、不改历史单、不修改审批状态;回滚时代码先回滚,列可保留至受控后续清理。
- 差旅单判定使用“任一明细的 `expenseType.category === travel`”或存在关联差旅申请,不新建另一套报销类型字典。
- 关联差旅申请时,概要可从已批准申请的 purpose、segments 城市、start/end date 读取;普通差旅报销使用自身四字段。历史数据只读降级为“description 作事由、明细最小/最大日期作起止日”,地点缺失如实显示,禁止从文件名、OCR 金额或自由文本猜地点。
- 四字段只允许申请人/受权代办在 `draft/rejected` 更改。审批中单不原地改业务事实;当前真实单如需完整地点,必须由主管退回后申请人补齐并重新提交。
- 详情页把差旅概要并入金额/报销人所在的单头概况;只有差旅单显示差旅行,不让普通费用报销承担空字段或独立空卡。
- 共享 `ReimbursementTravelSummary` 以“可编辑且概要不完整”作为初始填写态条件,直接呈现四个可见标签输入框;保存成功后切回摘要态,保存失败停留在表单并显示现有错误反馈。完整概要仍由用户点击“编辑”进入表单,不引入新的步骤、弹窗或报销类型。
### 13.4 四块式 KISS 交互边界
- 材料列表与合并预览复用现有单份签名 URL、企微 `previewFile`、PC 打开方式、下载理由、权限和审计;本轮只移除只读态的列表可见性条件,不增加后端接口。
- 列表顺序沿用详情 API 的发票、补充附件顺序;不按金额、文件名或关联明细重新排序。稳定 ID 作为 React key,整行按钮保留可见焦点和至少 44px 触控高度。
- PC/移动沿用现有详情路由和返回路径,只重排当前内容为四块纯滚动;不做 Tab、折叠、抽屉、普通/差旅二选一、费用日期交叉校验、补助自动计算或审批页重构。
- 真实风险以内联提示贴近单头/明细,关联项目或差旅申请以单头一行链接呈现;草稿/驳回动作留在页面底部命令区,均不计入稳定内容块。
### 13.5 五阶段处理进度 DTO
`GET /api/reimbursements/:id` 新增只读 `processingProgress`:
```text
processingProgress.stages[] = {
key,
label,
state, // completed | current | pending | exception
actorName?,
occurredAt?,
result?,
comment?,
detail?,
targetHref?
}
```
- 服务端固定生成“申请提交”以及“付款批次复核、付款与回单”首尾阶段;中间审批段先按查询的 `created_at,id` 顺序逐项生成真实 task,每个 task 拥有独立 stage key,不再用职责 Map 合并;再从提交时 `workflow_steps_snapshot` 的当前节点之后补齐尚未创建 task 的未来 pending 节点。当前两步审批规则从刚提交起就返回五个阶段,新增或重复角色节点会自然增加时间轴节点,不得丢失、覆盖或因延迟建任务而暂时消失。
- 审批标题优先使用非空且不属于“步骤/节点/step + 序号”的 `step_name`;否则把 `manager` 映射为“主管审批”、`finance|finance_mgr` 映射为“财务复核”、`ceo` 映射为“负责人复核”,其余统一为“审批”。标题和详情均不暴露原始 step ID、角色码或序号。
- workflow 实例已创建但 `workflow_tasks` 暂为空时,中间审批段按提交快照生成无姓名 pending 节点;workflow 整体不存在时才回退为“主管审批、财务复核”默认占位,避免申请刚提交时出现空白进度。
- workflow task 提供实际 assignee/completer/outcome,history 提供动作、意见和时间。已完成取实际 completer/actor,当前取真实 pending assignee;未来 task 尚未创建时 `actorName=null`,严禁按当前角色持有人猜姓名。
- 付款汇总读取与当前报销关联的最新 `PaymentBatchItem` 及其 batch,只选择状态、制单/复核/执行人员与时间和未归档回单计数,不返回批次其他报销、金额或材料。只有具备现有付款批次读取权限时才返回可跳转的 batch href。
- 没有批次时付款复核 detail 为“待财务创建付款批次”;draft 为“所在付款批次待非制单人复核”;reviewed 为“所在付款批次已复核,待执行付款”;failed/cancelled 如实表达失败或待重新创建。不得写成“您的报销待复核”。
- `paid` 后最后阶段完成;回单计数为 0 显示“付款已完成,回单待留存”,大于 0 显示“付款与回单已留存”。回单是最后阶段次级状态,不增加第六块。
- workflow、batch、receipt 三个来源分别 try/normalize;合法缺失不是错误,缺失来源只让对应及下游阶段保持 pending。数据库或映射异常仍按现有 5xx 处理并记录服务端错误,不用伪造“已完成”。
- PC 与移动复用一个时间轴组件。已完成无意见节点压成单行;当前、exception、returned/rejected 或有 comment 的节点展开。语义状态同时使用图标/文字/线条,不只依赖颜色。
### 13.6 验证与回退
- 后端覆盖迁移、PATCH/GET/submit 字段合同、日期倒置、非差旅报销不误拦截、关联差旅申请摘要与旧单降级。
- 后端补充进度 DTO 的 pending/approved/paid、退回/驳回、batch 五种状态、回单有无、三个来源独立缺失、未来节点无姓名和付款链接权限测试;另覆盖第三个未知职责节点、两个同角色节点互不覆盖、未来快照节点尚无 task、实例存在但 tasks 为空四条边界。
- 前端覆盖 PC/移动四块顺序、单头差旅填写/只读、材料列表与合并入口、变长审批节点紧凑时间轴、加载反馈、企微原件预览和 375px/横屏;组件只按数组 `map` 渲染,不按固定长度或位置解释职责。
- 若 JPEG/并发修复未通过目标镜像与合成火车票验收,回滚应用代码到现有 PNG 串行路径,保留原件降级;不删生产数据或回滚整库。
### 13.7 报销 UX 最小收口
- 报销列表把当前 `pathname + search` 作为 `location.state.from` 传给报销详情或审批任务;审批任务进入报销详情时把当前任务路径作为来源。PC 详情仅接受 `/reimbursements` 列表和 `/workflow/tasks/:id` 两类内部来源,拒绝绝对 URL、协议相对 URL、hash、当前页和其他模块路径。
- 报销详情在 `EntityOverviewHero` 之前渲染一个文字明确的左侧返回控件;返回采用 replace,避免“返回后浏览器后退又回详情”的循环。无可信来源时降级 `/reimbursements`。
- `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 缓存顺序与日志禁敏片段均有静态断言。
@@ -0,0 +1,381 @@
# 杨俊报销就绪:执行计划
## 0.1 报销 UX 最小收口(本轮)
- [x] PC 报销列表和审批任务向报销详情传递受控来源;详情标题左上方提供明确返回并删除业务动作区重复列表入口。
- [x] `displayFormat.ts` 新增共享人民币金额格式,PC/移动报销详情统一复用。
- [x] 移动草稿底部固定栏删除重复“添加发票”,材料区入口和提交主动作保持可用。
- [x] 聚焦 Playwright 覆盖列表筛选返回、审批任务返回、直接打开降级、金额格式与移动单一入口;再运行 typecheck、build 和治理门禁。
- [x] 本轮只做本地实现与验证;未获部署批准前不发布、不修改生产配置或真实业务数据。
## 0.2 R5 移动契约与门禁收口(本轮)
- [x] 修正 R5 首页指标采集:从 `mobile-action-grid` 的直接入口动态取得 key 与布局,不再维护已经失效的旧 key 列表。
- [x] 对经理测试角色明确断言有序入口集合为 `new-reimbursement / capture / leave / purchase`,保留 44px、单行、SVG、边界和无重叠检查。
- [x] 将 R5 登记到 `route-manifest.json` 的 `acceptanceSpecs`,使 `run-local-candidate.sh` 的 list/focused/full 都能解析并执行该用例;不重复加入另一个门禁造成同轮双跑。
- [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 新鲜度;输出仅限状态和计数。
- [x] 使用活动部署的显式合同参数重装 automatic backup 的 daily/watchdog/drill-reminder units。
- [x] 使用活动部署的显式合同参数重装 WARCH3 unit。
- [x] 验证以下 4 个 service 的 `BUILD_SHA` 全部等于活动 server,相关 timers 为 enabled/active:
- `hyb-oa-automatic-backup-daily.service`
- `hyb-oa-automatic-backup-watchdog.service`
- `hyb-oa-automatic-backup-drill-reminder.service`
- `hyb-oa-warch3-ops.service`
- [x] 手工启动一次 automatic backup daily service;确认源端 `Result=success`、`ExecMainStatus=0`。
- [x] 在 ARM 只读验证新批次完成、manifest/文件完整性与新鲜度检查通过;任何失败均停止人员写入。
## 2. 杨俊单人导入
- [x] 从管理员导出的通讯录以管道/内存方式筛选杨俊一人,不生成持久化子集文件,不输出个人字段。
- [x] 在生产容器运行 dry-run,验收计数:输入 1、active 1、incomplete 0、planned create 1、conflict 0、skip 0、failure 0。
- [x] dry-run 符合预期,无需进入偏差处理分支。
- [x] 经本任务最终实施批准后,使用 `docker-compose exec -T -e WECOM_DIRECTORY_EXPORT_APPLY ... --input=- --apply` 写入唯一目标。
- [x] 只读验证杨俊 OA 员工为 active、企微绑定唯一、初始角色严格为 `employee`,且其余通讯录成员未进入本轮输入或写入。
- [x] 确认生产企微通讯录写回门禁关闭;本轮同步结果为 skipped,未调用企微成员写接口。
## 3. 设置真实直属主管
- [x] 写入前记录杨俊原 `direct_manager_id` 的脱敏状态,并确认马建勇为 active、不同员工且具有 `expense.approve`。
- [x] 通过现有 `POST /api/users/batch/set-manager` 仅将杨俊的主管设置为马建勇;不直写数据库、不调整任何人的角色。
- [x] 写后验证主管解析成功,并核对 `SYSTEM_USERS_BATCH_SET_MANAGER` 审计增量;该接口合同不递增目标员工 token version 或撤销会话。
- [x] 主管写入成功,未进入失败或误配回退分支。
## 4. 聚焦验证
- [x] 运行通讯录导入相关测试,覆盖手机号为空、单目标和 apply 双门禁。
- [x] 运行 users set-manager 路由测试,覆盖 active、自指、审计和权限边界。
- [x] 运行 workflow/reimbursement 聚焦测试,覆盖主管解析、`expense.approve`、无差旅申请和绑定票据提交门禁。
- [x] 运行 `cd server && npm run prisma:validate`;代码未变化时仍将结果作为当前合同基线记录。
- [x] 运行 Trellis task/governance、流程/文档、敏感扫描和 `git diff --check`。
- [x] 按项目规则对权限、workflow 和生产写入证据做一次只读 review;Codex inline 主代理复核通过,未发现角色扩权、企微反向写入、审批自审或生产测试业务数据。
## 5. 两个既有企微测试成员的受控验收
- [x] 从管理员通讯录以内存方式仅筛选“测试员工”和“测试主管”,验证二者唯一、active、资料满足导入合同;不输出或持久化原始字段。
- [x] 生产 dry-run 验收输入 2、计划创建 2、冲突/跳过/失败 0;偏差即停止。
- [x] 单独取得生产测试账号/业务写入批准后 apply,并确认两人 active、企微绑定各自唯一、没有导入其他成员。
- [x] 通过现有角色接口将角色收敛为:测试员工=`employee`,测试主管=`employee + manager`;核对 token version、会话撤销及角色审计。
- [x] 通过现有主管接口设置“测试员工 → 测试主管”,确认测试主管 active 且具有 `expense.approve`。
- [x] 两个测试成员均完成账号密码登录与首次改密,申请人可见报销入口、主管可见审批入口;企微 SSO 因不能冒充企微身份仍待人工确认。
- [x] 测试员工创建唯一一条描述含“系统测试、非真实业务、禁止付款”、总额 0.01 元的测试报销,并上传不含真实资料的安全 PDF。
- [x] 合成 PDF 经生产 mock OCR 自动生成测试字段并标记可用,绑定后成功提交;该结果同时暴露真实使用前必须移除 mock OCR 的门禁。
- [x] 测试主管确认待办可见并执行“退回”,未执行“通过”;报销回到 `draft` 且 workflow instance 终止,无需追加“撤回”。
- [x] 测试员工删除 draft 报销;票据解绑回到 `available` 后已手工归档。
- [x] 管理员停用两个测试 OA 账号并确认会话撤销、企微写回为 skipped;企微侧两个测试成员保持不变。
- [x] 只读收口确认:测试报销 0、pending 待办 0、测试票据唯一且为 manual archived、两个测试 OA 账号为 archived、活动会话 0、企微绑定保留 2;未产生测试付款。
## 5.1 真实发票路径门禁
- [x] 只读确认生产当前为 `OCR_PROVIDER=mock`,腾讯 OCR 凭证未配置。
- [x] 用户选定“关闭 mock 并采用财务查看 PDF 原件人工核对”;本轮不改代码、不部署新构建。
- [x] 生产 `.env` 已切换为 `OCR_PROVIDER=off`;现场无独立 worker 且 `JOBS_ENABLED=true`,因此仅强制重建 server。公网 `/ready` 正常,构建仍为 `4886f9b081dd`。
- [x] 重新启用两个测试 OA 账号并完成受控 API 闭环:新 PDF 直接 `available`,八个发票业务字段为空、OCR 尝试为 0,绑定、提交、主管退回均成功。
- [x] 复验后删除测试报销、归档测试票据、停用测试 OA 账号;最终活动会话 0、测试报销 0、pending 待办 0、未归档测试票据 0,企微同步均为 skipped。
- [x] 记录非阻断 schema finding:off 路径未显式写 provider,但 `ocr_provider` 因非空默认值仍存 `tencent`;请求/完成时间和 OCR 结果为空,当前 UI 不展示该字段。
## 6. 用户真实验收
- [x] 在杨俊提交前通过现有审批规则管理接口把三档规则收敛为唯一 `[0,+∞)` 规则,审批链固定为 `department_manager → finance`;不扩角色、不保留 CEO 重复节点。
- [x] 规则写入前后只读核对 499.99 / 500 / 5000 三个金额,确认解析结果完全相同且 active `finance` 有唯一实际处理人。
- [ ] 请杨俊本人从企业微信进入 OA,确认身份解析成功并能看到报销入口。
- [x] 杨俊使用真实业务数据创建报销,至少添加一条费用明细并上传/绑定状态有效的真实票据后提交。
- [ ] 确认马建勇能看到该真实审批待办并按业务事实处理。
- [ ] 审批通过后确认单据进入王芝玲负责的财务处理链路;后续付款批次继续遵守既有 maker-checker。
- [ ] 用户验收期间不在聊天、截图或任务证据中粘贴票据、金额、银行资料或完整账号。
## 6.1 审批详情可达性修复
- [x] 保留移动待办列表卡片现有 `handleTaskSelection` 行为,不修改任务列表到审批任务详情的路由。
- [x] 在 `MobileApprovalsPage` 的审批任务详情报销摘要区增加“查看完整报销单”,复用 `/mobile/reimbursements/:id` 和现有 `state.from` 来源回跳约定。
- [x] 在 `WorkflowTaskDetailPage` 的报销摘要区增加“查看报销单”,复用 PC 报销详情路由。
- [x] 不修改后端、审批状态机、权限矩阵或生产数据;请假、采购、差旅同类入口缺口仅记录,不在本轮扩展。
- [x] 增加聚焦前端回归,验证移动进入/返回及 PC 入口;运行前端 typecheck、build、Trellis task/governance 和 `git diff --check`。
## 6.2 审批详情入口生产发布
- [x] 将工作提交 `d2841b1` 推送后,以 `SYNC_ENV=0` 按标准发布脚本部署;未覆盖生产 `.env`,未修改审批状态、真实业务数据或付款数据。
- [x] 第一次发布尝试在同步前因附件挂载目标解析不匹配停止,生产未变化;按活动容器的实际挂载目标修正输入后重新执行。
- [x] 发布来源校验、环境合同、远端 Compose 校验、Prisma migration status、`/ready`、external deps smoke、runtime smoke 与公网资源扫描均通过,活动构建为 `d2841b166dc0`。
- [x] 使用既有 unit 中的受控合同参数重装 automatic backup 三项 service/timer 与 WARCH3 service/timer;第一次读取活动容器时因未向 Compose 显式传入构建变量而在 unit 写入前失败,补齐目标构建参数后成功。
- [x] 4 个 service 的 `BUILD_SHA` 均与活动构建一致;4 个 timer 均为 enabled/active。
- [x] 手工触发 automatic backup daily,源端 `Result=success`、`ExecMainStatus=0`;随后源端 watchdog 同为 `success/0`。daily 只有在 ARM receiver 完成三文件、manifest 与 SHA-256 校验并原子 finalize 后才会成功。
- [x] ARM dead-man timer 保持 enabled/active,最近一次自动运行是 `success/0`;本次尝试立即手工复跑被已收口的最小权限策略拒绝,未恢复临时 sudo、未改 ARM。不得把该证据写成“独立 dead-man 已对新批次即时复跑”。
- [x] 公网 `/ready` 构建标识匹配,已发布的移动端和 PC 资源分别包含“查看完整报销单”和“查看报销单”;未读取、截图或输出真实报销内容。
## 6.3 报销详情时区与 KISS 收敛
- [x] 让共享日期时间格式化显式使用 `Asia/Shanghai`,PC/移动报销详情与 PC 审批任务详情移除本地字符串截取函数并复用共享实现。
- [x] PC 合并重复概况,收敛明细、发票、附件标题和解释;无关联或无预算/风险内容时隐藏对应辅助区块。
- [x] 移动概况删除重复状态、计数教学和冗余下一步文案;只读无动作时不渲染空操作卡,编辑能力保持不变。
- [x] 用合成数据补附件 UTC→上海时间和 KISS 信息层级聚焦回归;运行前端 typecheck、build 与相关 Playwright。
- [x] 运行 Trellis task/governance、流程合同、文档审计、敏感扫描和 `git diff --check`;生产部署另行批准。
## 6.4 企微 WebView 旧包刷新护栏
- [x] 在页面从后台恢复为可见时请求 `/ready`,比较规范化后的前端与 server 构建标识;一致、未知或请求失败时静默。
- [x] 构建不一致时复用现有 PWA 更新事件,在已登录 PC/移动壳显示“发现新版本,点击刷新”;不自动重载页面。
- [x] 点击刷新后清理旧 PWA runtime/precache 并重载;同一页面生命周期只提示一次。
- [x] 用合成 `/ready` 响应覆盖 mismatch、match 和 failure,运行 typecheck、build、聚焦 Playwright 与 Trellis 门禁。
- [x] 只提交并推送;生产部署和经理二次刷新另行执行,不在本步骤自动部署。
## 6.5 企业微信原生发票与附件预览
- [x] 增加企微 JSAPI ticket 获取/缓存和本站 URL 签名接口;覆盖缺配置、上游失败、跨 origin、fragment/凭据 URL 拒绝与脱敏返回。
- [x] 发票短时 URL 响应补准确文件元数据;移动企微用 `previewFile`,PC 用浏览器预览。
- [x] 为报销附件增加严格对象授权、理由门禁和实际取件审计的短时预览 URL;不扩大角色或通用下载权限。
- [x] 抽取 PC/移动共用材料预览调用,企微路径不使用异步 `window.open(blob:)`;只读审批人看得到查看动作但看不到编辑、解绑、删除动作。
- [x] 用合成文件与 mock JS bridge 运行后端聚焦测试、前端 typecheck/build 和 PC/移动 Playwright 一致性回归。
- [x] 运行 Trellis 治理、流程/文档、敏感扫描、`git diff --check`,并对新增短时授权边界做只读 review;不在本步骤部署生产。
## 6.6 原生预览生产发布与运维对齐
- [x] 将提交 `410d8cd` 推送后以 `SYNC_ENV=0` 发布;未覆盖生产 `.env`,62 个 migration 无待执行项,公网 `/ready` 的 12 位构建标识匹配。
- [x] 如实记录两次发布尝试:第一次使用 runbook 示例发布目录和普通运维入口,来源/环境合同通过,但 rsync 因目录权限在同步前失败,生产未变化;随后从活动 Compose 标签解析实际发布根目录,复用既有受控 root 运维入口成功发布,未新增 sudo 或修改目录权限。
- [x] external deps、runtime smoke 与公网 bundle 扫描通过;公网包包含 `previewFile`、同源 JS-SDK 签名接口和附件短时预览入口,未登录请求签名接口返回 401。
- [x] 发布后从既有 unit 内部复用受控合同参数和运行时间,仅把 automatic backup 三项与 WARCH3 的构建标识刷新为 `410d8cd`;4 个 service 均匹配活动构建,4 个 timer 均为 enabled/active。
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;daily 成功以异机接收端完成文件、manifest、哈希校验和原子 finalize 为既有合同。
- [x] 记录非阻断依赖 finding:本次镜像构建审计报告 client 18 个风险(10 high),server 32 个风险(20 high、2 critical);未在发布窗口执行自动升级,留待独立依赖升级和回归任务。
- [x] 记录证据最小化 finding:既有 external/runtime smoke 输出仍包含不必要的配置标识和测试管理员联系方式;本任务不复述这些值,后续应独立收敛为状态与计数输出。
- [ ] 请用户在企微自建应用刷新后先用无业务数据 PDF 验证原生预览,再由马建勇查看当前真实报销的发票与附件;材料真实可见前不得完成主管审批。
## 6.7 移动报销查看范围对齐
- [x] 在 `MobileReimbursementsPage` 读取 `expense.view_all`,仅对有权用户显示“我的 / 全部”范围切换;复用现有 Tabs/按钮样式,触控高度为 48px。
- [x] 范围默认和无权限回退均为 `own`;切换 `all` 时写入 URL 查询参数并纳入 React Query key,调用现有 `fetchReimbursements({ scope })`,不改后端或扩大权限。
- [x] 列表到详情的 `state.from` 保留当前 status 和 scope;普通员工不渲染“全部”入口,“全部”范围不显示仅限本人草稿的删除动作。
- [x] 增加聚焦 Playwright:管理员默认本人、切换全部后看到他人单据并进入详情;普通员工即使手工带 `scope=all` 也不显示范围切换且请求保持 `scope=own`。
- [x] 运行前端 typecheck/build、聚焦 Playwright、task/governance、流程/文档、敏感扫描和 `git diff --check`;部署另行批准。
## 6.8 移动范围生产发布
- [x] 将 `20b20b1` 和 `8b376f4` 推送后,以 `SYNC_ENV=0` 部署 `8b376f4`;未覆盖生产 `.env`,无待执行 migration,`/ready`、external/runtime smoke 与公网扫描通过。
- [x] 如实记录三次本地发布入口尝试:第一次因临时日志清理命令被本地安全护栏在进程启动前拒绝;第二次在来源校验阶段因未跟踪 portfolio review 导致 dirty worktree 停止;两次均未同步或改变生产。仅临时 stash 该目录后第三次发布成功,随后按 stash 标识原样恢复且未纳入提交。
- [x] 公网 `/ready` 为 `8b376f413585`,活动 server 中唯一移动报销 chunk 包含 `mobile-reimbursements-scope-all` 标识。
- [x] 复用既有 unit 合同参数与运行时间刷新 automatic backup 三项和 WARCH3;4 个 service 均匹配 `8b376f4`,4 个 timer 均 enabled/active。
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
- [x] 用户已用自己的企微管理员账号进入“报销 → 审批中 → 全部”,打开当前真实报销并确认能够查看材料;本项只记录查看验收,不代表主管审批或财务链路已完成。
## 6.9 报销明细与材料 KISS 对齐
- [x] PC 与移动端按 `linkedItemId` 在每条费用明细内展示关联发票摘要,并复用当前安全预览动作;未关联发票不归入明细。
- [x] 将原发票区和附件区收敛为单一“报销材料”区,显示分类计数,全部材料信息行可点击预览且不再重复显示“查看原件”按钮。
- [x] 保留草稿编辑态的添加/移除发票与上传/删除附件,不改后端合同、对象授权、审计、签名 URL、企微 JS-SDK 或审批状态机。
- [x] 更新聚焦 Playwright,覆盖关联/未关联发票、统一计数、PC/企微预览、编辑态动作、44px 触控目标和移动横屏无溢出;前端 typecheck/build 与 Trellis 质量门禁通过。
- [x] 用户明确批准后推送并部署生产;真人业务验收仍由用户在企微刷新后执行。
## 6.10 明细/材料 KISS 生产发布
- [x] 推送 `665dcab` 与 `0e44aa7`,从干净的已发布提交快照以 `SYNC_ENV=0` 部署 `0e44aa7`;不触碰未跟踪 portfolio review,不同步生产 `.env`。
- [x] 发布脚本的来源、环境合同、init、migration、`/ready`、external/runtime smoke 和公网资源扫描通过;公网构建标识为 `0e44aa72f3f5`,活动资源包含 PC/移动统一材料区标识。
- [x] 复用现有 unit 参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份一致,4 个 timer 均 enabled/active。
- [x] 手工触发 automatic backup daily 和源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次或付款写入。
- [x] 用户验收后修正方向:不需要在费用明细内查看关联发票;现有逐份材料行仍然繁琐,需要一次进入连续查看全部材料。该反馈覆盖 6.9 的内联发票展示合同,已部署事实保留但不再作为目标体验。
## 6.11 票种摘要与整单材料预览
- [x] 只读核查生产真实报销:绑定票据 3 张,均为 `bound`、腾讯 OCR 已实际执行且无存储错误;号码/日期/金额完整。票种为 2 张电子火车票和 1 张数电发票,传统代码均不适用,火车票销售方字段不适用;未输出号码、金额、主体、文件名、UUID 或原始 OCR 结果。
- [x] 核查企微 SDK 合同:`previewFile` 仅接受单个 `url/name/size`;只有图片接口 `previewImage` 接受 `urls[]`。当前真实材料为 3 份 PDF 发票和 3 份 PNG 补充附件,总源文件小于 1 MiB,适合逐份 PDF 栅格化后组成有序图片包验证连续预览。
- [x] 用户批准采用服务端 PDF 栅格化 + 企微 `previewImage`,其余按统一材料入口、票种摘要和原件降级方案实施;不采用合并 PDF 或 PDF.js。
- [x] 删除 PC/移动费用明细中的关联发票 UI 和对应旧断言,保留后端 `linkedItemId`。
- [x] 报销详情 API 增加安全票种分类映射;共享票据格式化按普通增值税、数电发票和电子火车票显示适用字段,不重跑或改写当前真实 OCR 数据。
- [x] 在运行镜像加入固定 Poppler 工具,结合既有 `sharp` 实现有界、短时、对象授权的报销级图片预览 grant;企微一次 `previewImage` 原生滑动,PC/普通浏览器连续浏览,失败展开逐份原件预览降级。
- [x] 更新后端路由/授权/审计/超限/缓存失效测试与 PC/企微 Playwright;运行 Prisma validate、server 聚焦测试与全量基线、client typecheck/build、Trellis/governance/process/docs/sensitive/diff 门禁。
- [x] 稳定合同验收后再反向更新 `reimbursement-invoice-ocr.md` 与 `wecom-boundary.md`,覆盖当前“明细内发票、无聚合接口”的旧规范;提交、推送、生产部署和真实材料查看分别遵守既有批准门禁。
## 6.12 整单材料预览生产发布
- [x] 提交并推送 `8a9279e`,以 `SYNC_ENV=0` 发布;未覆盖生产 `.env`,未修改权限、审批状态机或任何真实业务数据。
- [x] 如实记录发布过程:首次从历史 Compose working-dir 标签解析路径,在同步前因普通用户权限停止,生产未变;一次临时 clone 长时间无进展后主动中止,未进入 deploy;一次后台日志方案在进程启动前被本地安全护栏拒绝,未改变生产。
- [x] 一次本地长构建连接被中断,但远程构建继续完成;当时仅源码已同步、活动容器未切换。最终复跑复用已完成的构建缓存并全流程成功。
- [x] 发布来源、环境合同、Compose/init、62 个 migration、external/runtime smoke 与公网资源扫描通过;公网 `/ready` 与活动容器均为 `8a9279e`,容器内 `pdftoppm` 可用,未认证材料包接口返回 401。
- [x] 发布后发现 automatic backup 三项与 WARCH3 的 unit 仍为旧构建身份;只读复用现有 unit 参数与运行时间重装后,4 个 service 全部匹配 `8a9279e`,4 个 timer 均 enabled/active。
- [x] 手工触发 automatic backup daily 与源端 watchdog,二者均为 `success/0`;未执行真实审批、财务审核、付款批次、银行回单或付款写入。
- [x] 构建耗时主要来自首次加入 Poppler 系统包后的基础层失效:系统依赖安装及 client/server/init 重新构建;migration 无待执行项,不是本次耗时主因。
## 6.13 生产真机验收缺口修正
- [x] 将移动端“查看全部材料”从报销明细区移到报销材料区;PC 保持现有正确位置。
- [x] 用 `pdf-lib` 实现确定性合并 PDF:显式排序、全部 PDF 页、图片各一页、固定元数据/保存选项、集合哈希缓存;不使用 `ignoreEncryption`。
- [x] 用 Node `worker_threads` 受限执行合并并设置材料数、源字节、页数、图片像素、输出大小和超时;不启用 Compose worker profile。
- [x] 将材料 bundle 签发改为单一 PDF URL + 准确 `name/size`,企微一次 `previewFile`、PC 浏览器原生预览;缓存缺失、加密/损坏/超限时整包失败并展开逐份原件。
- [x] 删除旧图片页 GET、共享图片浏览页和 `previewImage` 主路径;确认无其他用途后移除 Poppler 镜像依赖。
- [x] 新增报销差旅事由/地点/开始/结束四个 nullable 列和加法迁移,更新 PATCH/GET/submit 合同,不回填或改写审批中真实单。
- [x] PC/移动差旅单在首部概况与明细之间显示轻量差旅概要;草稿/驳回可编辑,审批中只读,历史单只用说明/明细日期降级且不猜地点。
- [x] 后端聚焦测试覆盖字节确定性、显式顺序、页数、准确 size、缓存缺失、加密/损坏回退、全部上限、差旅字段验证与非差旅不误拦截;前端 Playwright 覆盖按钮位置、加载文案、企微 `previewFile`、PC 打开和概要。
- [x] 运行 Prisma validate、新建空库 migration、server 聚焦/全量、client typecheck/build、无业务 25 页合并探针、Trellis/governance/process/docs/sensitive/diff 门禁。
- [x] 只读 review 迁移、提交门禁、短时合并件权限与审计。
- [x] 实现提交、推送和生产部署均已分别取得批准并完成。
- [ ] 企微真机用当前真实报销验证合并 PDF 可打开、全部页可连续查看且返回详情正常;该人工结果不由自动化或发布 smoke 代替。
## 6.14 合并 PDF 与差旅概要生产发布
- [x] 实现提交 `da35591` 已推送到 `origin/main`;从该提交的干净临时 clone 执行标准 `deploy/trialrun/deploy.sh`,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`,未触碰未跟踪的 portfolio review,也未同步生产 `.env`。
- [x] 如实记录第一次本地发布入口尝试:命令末尾包含临时 clone 清理动作,被本地安全护栏在进程启动前拒绝;未连接生产、未同步文件、生产没有变化。去掉清理动作后第二次发布全流程成功,完成验证后再校验临时目录前缀和提交身份并删除该临时 clone。
- [x] 发布来源校验确认 `da35591` 已进入远端;环境合同、Compose 预检、init 和 migration deploy 通过。63 个 migration 中新增 `20260801120000_reimbursement_travel_summary` 成功应用,schema 随后为 up to date;迁移只增加四个 nullable 列。
- [x] `/ready`、external deps smoke、runtime smoke 和公网前端资源扫描通过;公网 12 位构建标识与容器完整 SHA 分别匹配 `da35591`。构建仍报告既有 npm 风险基线:client 18 个风险(10 high),server 23 个风险(10 high、2 critical);本轮未执行自动升级。
- [x] 发布后只读验证目标 server 可加载 `pdf-lib`,且 `pdftoppm` 已从运行镜像移除;未认证材料合并签发和带无效 token 的取件请求均保持 401/403 边界。
- [x] 发布后首轮独立 `/ready` 复核误把公网 12 位前端标识按完整 40 位后端 SHA 断言,检查在只读响应处停止;未执行后续动作或改变生产。按既有“公网 12 位 + 容器完整 SHA”合同修正后复核通过。
- [x] 复用既有 unit 内的受控合同参数重装 automatic backup 三项与 WARCH3;4 个 service 构建身份均匹配 `da35591`,4 个 timer 均 enabled/active。手工触发 automatic backup daily 和源端 watchdog,结果均为 `success/0`。
- [x] 本次发布未修改真实报销状态、审批任务、权限、付款批次、银行回单或业务材料;企微真机合并 PDF 体验和真实报销后半链继续由对应真人验收。
## 6.15 首笔历史差旅概要受控补录
- [x] 写入前只读锁定唯一目标:当前真实报销仍为审批中、当前审批任务尚未处理、四个差旅概要字段均为空;5 条 travel 明细中只有 1 条出发火车票日期早于申请人确认的实际行程,且正确日期已由业务方明确确认。
- [x] 以单一 Serializable 数据库事务补录用户确认的事由、地点和起止日期,并只把上述唯一错误火车票明细日期改为确认后的实际乘车日期;未改金额、费用类型、说明、票据、附件、审批状态、工作流实例或待办。
- [x] 同事务新增 1 条管理员代录审计,只记录“字段于提交后上线、原提交未采集、业务方确认后代录”的原因、被修正字段名和受控 task 标识;未把真实事由、地点、日期、票据或原始请求写入 task 证据或审计 extra。
- [x] 写后只读断言通过:差旅概要四项完整、5 条 travel 明细日期全部位于确认区间、金额与 3 张发票和 3 个附件保持不变、工作流与当前待办仍为原 pending 状态、目标审计恰好新增 1 条;事务结果为 `applied`,未触发回滚。
- [x] 用户刷新当前真实报销后确认页面日期显示正确;该结果只证明日期展示,不代替差旅概要其他字段、材料列表或真实审批结果。
- [ ] 补录不代表审批通过;马建勇仍须按业务事实完成主管审批,后续财务、付款批次、银行回单和最终复核继续遵守既有流程。
## 6.16 四块式详情与五阶段处理进度
- [x] PC 与移动端移除只读态材料列表的权限/失败条件,所有有权查看当前报销的人始终看到发票与补充附件列表;合并预览保留在列表上方。
- [x] 单份材料继续使用现有整行按钮和单份预览路径;只读审批人没有移除、删除或管理动作,编辑态既有动作保持不变。
- [x] PC/移动详情重排为“单头概况 → 费用明细 → 报销材料 → 处理进度”四块;差旅概要并入单头,关联/风险/付款/操作按数据与权限就近显示,不增加 Tab 或折叠。
- [x] 共享差旅概要组件在“草稿/驳回可编辑且字段不完整”时直接进入四字段填写态;完整概要保持单头内摘要 + 编辑,不新增报销类型或字段合同。
- [x] 后端详情新增五阶段 `processingProgress`,从 latest workflow snapshot/tasks/history、关联 PaymentBatchItem/batch、报销付款字段和回单计数生成;不改 schema、workflow 或 PaymentBatch 状态机。
- [x] 固化未来节点无姓名、共享批次措辞、maker-checker 各状态、回单次级状态、付款链接权限和三源独立缺失降级;返回中不包含批次其他单据、金额或材料。
- [x] PC/移动复用紧凑纵向时间轴:已完成无意见节点单行,当前/异常/有意见节点展开;不显示“第 N 步”、内部 step ID、角色码或权限码。
- [x] 更新后端聚焦测试与 PC/移动 Playwright,覆盖五阶段、退回/驳回、batch 状态、回单、来源缺失、未来无姓名、材料单份/合并、编辑权限、四块顺序、44px 触控和横屏无溢出。
- [x] 运行 Prisma validate、touched-file `node --check`、server 聚焦/必要全量、client typecheck/build、聚焦 Playwright、task/Trellis/process/docs/sensitive/diff 门禁;只读 review 跨 workflow/付款状态映射与权限,不让 reviewer 改代码。
- [x] 实现完成后分别取得提交、推送和生产部署批准;部署与公网检查已通过,未执行真实审批、付款或回单写入。
## 6.17 审批节点数据驱动修正
- [x] 移除 `classifyApprovalTask` + 阶段键 Map 覆盖路径;固定首尾业务阶段,中间按 `workflow_tasks` 的真实稳定顺序逐任务生成唯一节点,并按提交快照补齐尚未创建 task 的未来审批节点。
- [x] 节点标题按“可读 step name → assignee role 职责映射 → 审批”降级,过滤“步骤/节点/step + 序号”,不显示 step ID、角色码或序号。
- [x] workflow 整体不存在时保留主管/财务 pending 占位,实例 tasks 暂为空时按快照展示;前端 stage key 改为可扩展字符串并继续纯 `map` 渲染,不增加固定位置判断。
- [x] 新增第三个未知职责节点、两个同角色节点、未来快照尚无 task、空 task 实例单元测试,并复跑 `reimbursement-workflow-progress.spec.ts` 及既有四块/企微聚焦回归。
- [x] 重新运行后端聚焦与全量、Prisma、前端 typecheck/build、Trellis/process/docs/sensitive/diff 门禁;更新稳定审批投影规约后申请提交,随后按本轮授权推送、部署和公网检查。
验证记录:
- 服务端聚焦 16 项通过;最终全量 171 个文件、1129 项通过,Prisma validate 与 touched route `node --check` 通过。
- 前端 typecheck/生产 build 通过;Wave3 + R111 聚焦 2/2 通过,Wave3 同时覆盖 PC/移动 6 节点变长时间轴。
- `reimbursement-workflow-progress.spec.ts` 首轮在临时库首次改密页停止;隔离处理种子账号后,依次暴露手工服务未启 jobs、共享 Page Object 仍等待已不展示的文件名、盲取差旅费用类型却未填概要、旧断言硬编码主管/财务五阶段四个既有测试漂移。逐项收紧测试编排与断言后,最终真实临时 PostgreSQL + server + Mock OCR 路径 1/1 通过;应用认证、OCR、差旅和 workflow 生产合同未为测试放宽。
- workflow 实际只创建当前 task 的核查促使 DTO 继续用提交快照补齐未来 pending 审批节点;单元测试覆盖第三节点、同角色双节点、未来 snapshot-only 节点和空 tasks 实例。
- 本轮唯一明确命名的临时测试库已关闭服务并删除;未读取或写入生产业务数据,未执行真实审批、付款或回单。
## 6.18 四块式详情与动态审批节点生产发布
- [x] 将 `de2edd6` 至 `7bd80ef` 共 6 个已验证提交推送到 `origin/main`,从远端 `main` 的干净 clone 发布 `7bd80ef4f464`;当前工作区未跟踪的 portfolio review task 未进入发布来源。
- [x] 以活动 systemd unit 和容器只读解析既有部署、Compose 与数据目录合同,固定 `SYNC_ENV=0`、`DEPLOY_DATA_MODE=existing`;未同步生产 `.env`,未变更数据挂载、Compose 项目、worker/minio profile 或生产业务数据。
- [x] 如实记录发布前尝试:第一次包装命令因含临时目录删除而被本地安全护栏在进程启动前拒绝;第二次从 Compose working-dir 标签推断 env 文件失败;第三次干净 clone 被切成 detached HEAD,来源门禁报 `upstream_missing`。三次均在 rsync 前停止,生产未变化;第四次保持 `main` upstream 后全流程成功,未使用未发布 override。
- [x] 环境合同、远端 Compose 预检、init、migration deploy 与 schema status 通过;现有 63 个 migration 无待执行项。`/ready`、external deps、runtime smoke 和公网 93 个前端资源扫描通过,公网 12 位标识与活动容器完整 SHA 均匹配目标提交。
- [x] 独立复核按层校正断言:第一次误从 `/ready` 顶层读取构建标识,第二次误在前端 bundle 查找由服务端 DTO 生成的批次文案,均为只读检查脚本漂移且未改变生产。修正后公网 bundle 命中“处理进度/报销材料”,活动 server 源码命中共享批次职责文案,未认证材料合并签发保持 401。
- [x] 运行镜像确认旧 PDF 栅格化依赖已移除:`pdftoppm` 不存在,未安装 `pdfjs-dist`;合并主路径所需 `pdf-lib` 与项目既有 `sharp` 均可加载。构建耗时主要来自 client/server 的 `npm ci` 依赖层重装,前端 Vite 构建本身约十余秒;依赖升级和缓存优化不在本次发布窗口顺手处理。
- [x] 复用既有 unit 的受控参数和运行时间刷新 automatic backup 三项与 WARCH3;4 个 service 构建身份均匹配 `7bd80ef`,4 个 timer 均 enabled/active。手工触发 automatic backup daily 和源端 watchdog,结果均为 `success/0`。
- [x] 用户确认马建勇在企微真机刷新后已能正常使用本轮页面;该结果只关闭四块式详情、材料查看与动态处理进度的真机可用性验收,不代表主管审批已经完成。真实主管审批、财务复核、付款批次、银行回单和最终复核仍未完成,task 保持 `in_progress`。
## 7. 完成条件
- [ ] PRD 全部验收项有脱敏证据或明确由用户完成的真实业务验收结果。
- [ ] 本任务未导入杨俊和两个既有测试成员之外的其他成员、未创建法体、未补银行资料;唯一测试业务数据已按受控生命周期清理/归档。
- [ ] 若只完成系统就绪而真实报销尚未提交,任务保持未完成并明确剩余用户操作,不用合成测试替代。
- [ ] 完成后归档本任务,返回 R63–R66 产品主线;不从本任务继续长出新的运维加固范围。
## 8. 已完成的脱敏证据
- timer:4 个 service 构建合同一致,4 个 timer 均 enabled/active。
- 备份:源端真实批次 `success/0`,ARM 独立 dead-man `success/0`。
- 通讯录 dry-run:输入 1、active 1、缺手机号 1、incomplete 0、计划创建 1、冲突/跳过/失败 0。
- 通讯录 apply:创建 1、成功 1、冲突/跳过/失败 0;角色严格为 `employee`,企微绑定唯一。
- 主管接口:状态 200、更新 1、`SYSTEM_USERS_BATCH_SET_MANAGER` 审计新增 1;企微外部写入 0。
- 主数据:active 费用类型 37,active 报销工作流定义 1。
- 本地回归:5 个测试文件、61 项测试通过;Prisma schema 校验通过。
- 公网检查:`/ready` 为 ok、构建标识匹配,首页 HTTP 200。
- 只读 review:本轮只写 OA 员工、身份绑定和 OA 主管关系;未修改角色矩阵、workflow 代码、状态机、数据库 schema 或企微成员资料。
- 测试账号受控闭环:dry-run/apply 2/2、最小角色和主管关系通过;普通浏览器双账号登录、首次改密、0.01 元测试报销、合成 PDF 上传、提交、主管退回、草稿删除、票据归档均通过;最终 OA 测试账号 archived 2、活动会话 0、企微同步 skipped 2、测试报销与 pending 待办均为 0。
- finding:生产 OCR 为 mock 且真实腾讯 OCR 未配置,合成 PDF 自动生成 Mock 字段;草稿删除后页面导航前产生已删除详情/precheck 的 404 控制台噪声。前者阻断杨俊真实票据上传,后者不阻断业务。
- OCR off 复验:配置切换后未改构建;三次有业务写入的复验/诊断均按“退回 → 删除 → 归档 → 停用”收口。累计 4 张合成测试票据(含第一轮)均已归档,未归档 0;测试报销与 pending 待办均为 0。
- OCR off 结论:空发票业务字段可以绑定并提交,主管退回成功;真实使用暂由财务查看 PDF 原件并人工核对。文件 SHA-256 查重仍有效,发票号查重暂不可用。
- 真实首跑复核:三名参与人 active 且企微绑定唯一,但企微首登均未发生;费用标准 0、适用预算池 0、active 法体 0,法体不阻断当前报销。历史 mock 票据仅 1 张且为测试账号上传,非测试员工为 0。
- 审批规则 finding:`[0,500)` 可由马建勇和唯一 finance 王芝玲处理;`[500,5000)` 与 `[5000,+∞)` 均以 `finance_mgr` 收尾,但 active `finance_mgr=0`,达到 500 元会形成无人处理待办。未修正规则前不得笼统通知杨俊可提交。
- 审批规则收口:写入前报销 reviewing/工作流 pending/待办 pending 均为 0;备份 watchdog 与 ARM dead-man 均为 success。正式接口更新 3 次、审计新增 3,生产仅剩 1 条 `[0,+∞)` active 规则。
- 金额边界验证:实际工作流构造函数对 499.99、500、5000 均输出 `department_manager → finance`;直属主管解析到马建勇,active finance 唯一且由王芝玲承担。写后仍无报销、实例或待办新增,公网 ready 正常、构建标识未变化。
- 真实报销生产只读核验:杨俊最新真实报销为 `reviewing`,工作流为 `pending`,当前唯一待办为主管审批且已指派主管;付款批次 0。未输出金额、单号、UUID、附件或原始 payload,未执行生产写入。
- 真实验收 finding:主管能进入审批任务详情,但该页只有报销摘要,没有费用明细、发票、附件或完整报销单入口;因此尚未按业务事实审批。
- 审批详情入口本地实现:移动任务详情新增“查看完整报销单”,复用移动报销详情和 `state.from`;PC 任务详情新增“查看报销单”。未修改后端、权限矩阵、审批状态机或列表卡片点击行为。
- 首次聚焦 Playwright 运行因本地前端服务未启动,9 个用例均在 `/login` 前以 `ERR_CONNECTION_REFUSED` 失败,未执行到功能断言;启动 Vite 后改为只跑本次相关用例。
- 移动聚焦用例首次进入/返回链路通过,随后因固定在 2026-05 的“已处理”测试数据超过页面最近 30 天窗口而失败;已把完成时间改为运行时近期时间,避免日历漂移。
- 最终本地验证:`npm run typecheck` 通过,`npm run build` 通过;移动审批入口聚焦 Playwright 1/1 通过,PC 审批入口聚焦 Playwright 1/1 通过。
- 交付门禁:当前 task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 均通过;文档注册审计为 0 error、1 条既有未注册文档汇总 warning。
- 生产发布:`d2841b166dc0` 已部署,迁移无待执行项,发布脚本的 `/ready`、外部依赖、运行时和公网资源扫描通过;生产 `.env` 未同步。
- 发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配活动构建,4 个 timer 均 enabled/active;新 automatic backup daily 和源端 watchdog 均为 `success/0`。
- ARM 证据边界:新批次已由 receiver 校验并 finalize;ARM dead-man timer enabled/active、最近自动结果 `success/0`,但本次没有扩大 sudo 权限来即时手工复跑独立 dead-man。
- 公网入口:生产移动资源存在“查看完整报销单”,PC 资源存在“查看报销单”;只验证静态发布资源和构建身份,不读取真实报销数据。
- 报销详情时区:共享日期时间格式化固定 `Asia/Shanghai`;聚焦 Playwright 将浏览器强制设为洛杉矶后,合成 UTC 附件与审批时间仍分别显示为上海 `17:10`、`17:05`,1/1 通过。
- KISS 视觉复核:PC 与移动合成截图确认重复概况、状态、教学和空辅助区已收敛;未读取生产真实报销。Wave3 移动整段 1/1 通过;PC 大用例先通过本轮报销/审批断言,随后在未改动的合同页因找不到“筛选”按钮超时,因此不把该大用例记录为全绿。
- 编辑态回归:R111 移动发票/附件能力 1/1 通过;PC 首轮因旧断言仍要求空“预算与审核提示”显示而失败,按新合同更新断言后,PC 发票与附件流程 1/1 通过。
- 本地质量门禁:前端 typecheck/build 通过;task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有未注册文档汇总 warning。
- 企微旧包诊断:真机截图中的报销日期与已部署按钮属于同一个无额外条件的 JSX 块;公网 `/ready` 为部署构建且当前移动 chunk 包含入口文案,因此排除权限、报销关联缺失和未进入详情,结论为存活 WebView 旧包。
- 旧包刷新护栏:页面回到前台时用 `/ready` 与前端构建标识做一次 no-store 比对;不一致只提示用户刷新,点击后清理 PWA 缓存并重载,同一生命周期不重复提示;一致、unknown/development、异常响应和网络失败静默。
- 刷新护栏回归:mismatch/match/failure 聚焦 Playwright 3/3 通过,完整 login/portal UX 15/15 通过,typecheck/build 通过;375px 竖屏与 667×375 横屏无水平溢出,刷新按钮高度 44px。手机专项首次因测试在 React 壳监听器挂载前人工触发事件而失败,等待移动工作台渲染后复跑通过;未改生产或真实业务数据。
- 原生预览调研:官方 `previewFile` JSAPI 与腾讯维护的 `@wecom/jssdk` 均确认支持企微 iOS/Android;普通 PDF 链接“只下载”不等于原生 JSAPI 不支持预览。原生调用必须提供可直接取件的短时 URL、带扩展名文件名和准确字节数。
- 原生预览实现:服务端新增同源 JS-SDK 签名、发票元数据和附件 120 秒以内对象授权;客户端只在企微 WebView 动态加载 SDK。PC 发票使用短时 URL,PC 附件保留认证 Blob;移动发票/附件统一显示“查看原件”。未修改角色矩阵、workflow、数据库 schema 或真实业务数据。
- 短时授权只读 review:grant 绑定用途、报销、附件、actor 与过期时间;签发前检查当前对象权限和下载理由,实际取件审计失败时不发送文件。review 发现附件 GET 初版会在普通未认证请求前查询对象,已改为无 grant 先认证、有 grant 先验签再查对象,并用回归固定 401/403 前置拒绝。
- 原生预览回归:最终后端聚焦 3 个文件、46 项通过;服务端全量基线 169 个文件、1101 项通过;Prisma validate、touched server `node --check`、前端 typecheck/build 通过。聚焦 Playwright PC/企微 2/2 通过,mock bridge 收到准确 URL/name/size,企微路径未调用 `window.open`。
- 原生预览治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计 0 error、1 个既有注册汇总 warning。稳定例外已写入 `wecom-boundary`、`security-privacy` 与 `reimbursement-invoice-ocr` spec。本步骤未部署生产,真机和真实材料验收仍待单独批准。
- 原生预览生产发布:`410d8cd71b1f` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external deps、runtime smoke 与公网 bundle 扫描通过,未登录 JS-SDK 签名请求为 401。第一次尝试在同步前因示例目录权限失败且生产未变化,第二次通过活动 Compose 标签解析实际发布根目录并复用既有受控 root 运维入口成功。
- 本次发布后运维身份:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `410d8cd`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 新增 finding:镜像构建审计为 client 18 个风险(10 high)、server 32 个风险(20 high、2 critical),未执行自动修复;既有部署 smoke 还会输出不必要的配置标识和测试管理员联系方式,后续需分别建立依赖升级回归和证据最小化收敛项。
- 外部验收边界:生产已部署但企微真机中的无业务 PDF 原生预览、马建勇对真实发票与附件的可见性仍待人工验收;在材料真实可见前不得完成主管审批。
- 移动范围对齐:管理员移动报销页新增“我的 / 全部”,默认 `own`,选择“全部”后请求现有 `scope=all` 并把 status/scope 带入详情回跳来源;普通员工即使 URL 带 `scope=all` 仍请求 `own` 且看不到切换。首轮 Playwright 因未显式指定本地 Vite 地址而在登录前连接失败;指定 `E2E_BASE_URL` 后功能执行,首次严格 44px 断言测得浏览器子像素值略低于 44,控件提升为 48px 后最终聚焦 2/2、同文件完整回归 5/5 通过。375px 竖屏和 667×375 横屏无横向溢出,typecheck/build 通过。
- 移动范围生产发布:`20b20b1` 与 `8b376f4` 已推送,`8b376f413585` 以 `SYNC_ENV=0` 部署;无待执行 migration,公网 ready、external/runtime smoke、公网扫描和活动移动 chunk 标识通过。第一次本地命令在进程启动前被安全护栏拒绝,第二次因 portfolio review 令来源校验在同步前停止;临时 stash 后第三次成功,目录已原样恢复。4 个 service 构建身份匹配、4 个 timer enabled/active,新 daily 与 watchdog 均为 `success/0`。企微管理员真机的“全部”列表及发票/附件原生预览仍待用户验收。
- 真实材料查看验收:用户随后确认已能从自己的企微管理员账号找到当前真实报销并查看材料;该确认只关闭查看阻断,不代表主管审批、财务审核、付款批次或最终复核已经完成。
- 明细/材料 KISS 实现:PC 与移动端按 `linkedItemId` 在费用明细内显示可预览发票,未关联发票只留在统一“报销材料”区;材料区显示类型计数并以整行作为预览入口,后台类型、权限、审计、短时 URL 和企微 `previewFile` 均未改变。
- 聚焦 Playwright 首轮因本地前端服务未启动,PC/移动 2 项均在 `/login` 前 `ERR_CONNECTION_REFUSED`,未进入功能断言。启动 Vite 并指定 `E2E_BASE_URL` 后,两端首次都因旧测试仍假设销售方文本唯一而触发 strict-mode failure;这是新界面同时在关联明细和材料区展示同一发票摘要后的断言漂移,改为按区域断言。
- 下一轮 PC 通过,移动在“添加附件”后因测试仍从旧附件子区查找已移到统一材料区的隐藏 file input,等待至用例超时;页面快照确认实现已渲染且未发生业务错误。测试改为从材料区定位上传控件后,移动单项通过。
- 最终本地验证:生产 build(含 typecheck)通过;R111 PC/企微双端与 Wave3 只读时区/KISS 聚焦回归 3/3 通过,企微 `previewFile` 参数、PC 打开 URL、关联/未关联发票、材料计数、无逐文件“查看原件”按钮、44px 触控目标和 667×375 横屏无溢出均有合成断言。
- 最终治理:task validate、Trellis governance、process contract、敏感扫描与 `git diff --check` 通过;文档审计为 0 error、1 条既有汇总 warning(22 个历史未登记文件)。稳定展示合同已写入 `reimbursement-invoice-ocr` spec;未部署、未读取或写入真实业务数据。
- 明细/材料生产发布:`665dcab` 与 `0e44aa7` 已推送,`0e44aa72f3f5` 以 `SYNC_ENV=0` 部署;无待执行 migration,schema、`/ready`、external/runtime smoke 与公网资源扫描通过,公网递归扫描 91 个 JS 资源并在 2 个活动资源中命中统一材料区标识。
- 本次发布第一次本地命令因包含临时目录递归清理,被安全护栏在进程启动前拒绝,未生成快照、未连接或改变生产。第二次移除清理动作后,从干净 clone 发布成功;发布后按提交标识和受限临时目录模式删除本轮唯一快照,未保留构建日志。
- 发布后运维对齐:只读检查先发现 automatic backup 三项与 WARCH3 均仍为旧构建身份;复用现有 unit 参数和运行时间刷新后,4 个 service 匹配 `0e44aa7`、4 个 timer enabled/active。新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 本轮未同步生产 `.env`,未修改权限、审批状态机、企微认证或业务数据,未执行真实审批、财务审核、付款批次、银行回单或付款动作;真人企微 KISS 页面验收仍待用户刷新确认。
- 整单材料实现:PC/移动费用明细不再渲染关联发票;只读态以一个“查看全部材料”入口生成有序图片包,企微调用一次 `previewImage`,PC 使用共享材料页连续显示;编辑态和失败降级仍保留逐份原件管理/查看能力。
- 票种显示回归:合成数电发票与电子火车票分别显示正确票种;数电发票保留销售方,火车票使用购买方且不再出现“无代码 / 未识别销售方”;未重跑或改写生产 OCR 数据。
- 后端验证:聚焦 5 个测试文件、23 项通过;服务端全量 171 个文件、1109 项通过,Prisma validate 与 touched-file `node --check` 通过。新增断言覆盖顺序、页/源/输出/缓存上限、token 篡改、缓存缺失、下载理由、访问审计失败和安全票种映射。
- 前端验证:生产 build(含 typecheck)通过;R111 PC/企微 2/2 通过,覆盖费用明细无发票入口、票种文案、统一计数、材料页与一次有序 `previewImage.urls` 调用。
- 目标镜像验证:`server_build` 成功安装 `poppler-utils`;Playwright 生成的无业务合成单页 PDF 经容器内生产 `reimbursementMaterialPreview` 转换为 1 页 PNG,探针结果为 `raster-probe-pass`。未合并、改写或使用真实票据。
- 治理复核:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。首次敏感扫描命令误指向不存在的旧路径并立即失败,改用当前 `scripts/qa/check-sensitive-content.mjs` 后 30 个变更文件、0 finding;该失败未执行生产或业务写入。
- 本步骤只完成本地实现与验证;未推送、未部署、未修改生产配置或真实业务数据,不能据此通知真人线上测试。
- 整单材料生产发布:`8a9279efceee` 已部署,`SYNC_ENV=0`,62 个 migration 无待执行项;`/ready`、external/runtime smoke、公网扫描与未登录 401 边界通过,目标容器内 `pdftoppm` 可用。
- 本次长构建主要是 Poppler 首次进入生产基础层后引起系统包安装和 client/server/init 缓存失效;首轮路径权限、无进展 clone、启动前安全护栏拒绝和本地连接中断均已如实记录,未美化为一次成功。
- 合并 PDF 本地实现:材料按发票/附件各自 `created_at,id` 显式排序,集合哈希纳入缓存键;`pdf-lib` 复制全部 PDF 页,Sharp 仅处理图片,固定 PDF 元数据与保存选项,相同输入两次生成字节完全一致。
- Worker/边界验证:Node `worker_threads` 成功合并 25 页无业务 PDF 且页数完整;加密 PDF 和损坏 PDF 均整包失败,未使用 `ignoreEncryption`,未启用 Compose worker profile;同集合并发请求只生成一次。
- 签发与审计验证:接口只返回一个短时 PDF URL、名称、精确字节数和页数;GET 复核 `content-length` 与响应字节一致,token 篡改、缓存缺失和访问审计失败均在发送字节前拒绝。
- 前端回归:R111 PC/企微双端 2/2 通过;移动入口只在报销材料区,PC 只打开一个 PDF URL,企微只调用一次 `previewFile` 且 URL/name/size 精确;PC/移动均显示合成差旅概要。
- 数据合同验证:Prisma schema 通过;63 个 migration 在临时空 PostgreSQL 16 数据库完整 apply;新迁移只增加四个 nullable 差旅概要列,未回填或改写真实审批中数据。
- 最终质量:server 全量 171 个文件、1114 项通过;client typecheck/生产 build 通过;task、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过。文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。
- 发布后运维对齐:automatic backup 三项与 WARCH3 共 4 个 service 均匹配 `8a9279e`,4 个 timer 均 enabled/active;新 automatic backup daily 与源端 watchdog 均为 `success/0`。
- 四块式详情实现:PC/移动均收敛为“单头概况 → 报销明细 → 报销材料 → 处理进度”;差旅概要、关联事项和真实风险并入单头,材料列表对只读审批人常显,单份与合并预览沿用既有权限和审计路径。
- 五阶段只读投影:详情 API 固定返回申请提交、主管审批、财务复核、付款批次复核、付款与回单;付款批次未创建时可明确显示“待财务创建付款批次”,未来处理人不预填,普通申请人不获得批次跳转或批次其他业务信息。
- 五阶段验证:后端最新聚焦 12 项覆盖草稿、审批中、退回/驳回、批次 draft/reviewed/executed/failed/cancelled、回单有无、无批次历史付款和批次链接权限;服务端全量基线 171 个文件、1123 项通过。Prisma validate 与 touched route `node --check` 通过。
- 双端验证:client typecheck/生产 build 通过;R111 PC/企微 2/2、Wave3 只读详情 1/1 及移动视觉聚焦 2/2 通过,覆盖材料常显、只读无删除、四块顺序、五阶段无“第 N 步”、44px 触控和无横向溢出。视觉复核确认 PC 主内容不再因空侧栏过窄。
- 只读 review:按 workflow task、PaymentBatchItem/batch、回单计数三源逐项复核映射与权限;修正“workflow 已通过但尚未建批次”原本无当前节点的问题,使其明确落在“待财务创建付款批次”。确认未来 checker 不预填、普通申请人无批次链接、共享批次不暴露其他单据/金额/材料;review 未修改状态机、权限矩阵或生产数据。
- 最终治理:task validate、Trellis governance、process contract、敏感扫描和 `git diff --check` 通过;文档审计 0 error、1 条既有汇总 warning(22 个历史未登记文件)。本步骤未部署、未推送、未修改生产配置、真实报销状态、付款批次、回单或业务数据。
- PC/移动 UX 最小收口:PC 从带 `status/page/scope` 的报销列表进入审批任务后可返回同一查询,从审批任务进入报销详情后显示“返回审批详情”,直接打开详情显示“返回报销列表”;业务动作区不再重复列表入口。共享 `formatMoney` 统一总额、明细、发票和标准提示为人民币两位小数;移动底栏只保留“提交审批”,材料区继续保留“添加发票”。
- UX 聚焦验证:Wave3 返回/金额/视觉相关 5 项通过,R111 PC/企微材料相关 2 项通过;typecheck 与生产 build 通过,截图确认 PC 返回位于标题上方、移动底栏为单一主按钮。旧 R5 综合移动用例 2 项在进入报销详情前因采集器仍查 `approvals/reimbursements/me` 等旧 key 而只识别出 2 个入口;页面对该经理角色实际仍渲染“新建报销、上传票据、请假记录、采购”4 项,上一轮“实际为 2 个”的表述已在本轮勘误。
- R5 门禁收口:测试从常用动作宫格动态采集入口,但固定断言经理角色的 4 项 key/标签集合,并保留双设备 44px、单行、SVG、边界和无重叠检查;详情返回按当前统一详情壳断言为按钮。R5 单跑 2/2 通过,并登记到 `route-manifest.json` 的 `acceptanceSpecs`。
- 候选门禁修复:`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 个历史未登记文件)。本步骤只完成本地流程加固,尚未提交、推送或部署。
@@ -0,0 +1,147 @@
# 杨俊报销就绪
## Goal
让已在企业微信通讯录中的杨俊尽快具备真实报销能力,同时避免让真实员工承担首轮试错:先使用通讯录中既有的“测试员工”和“测试主管”两个企微测试成员,在生产 OA 完成一条受控、可清理的报销体验验收;验证通过并清理测试数据后,再请杨俊通过企微进入 OA、创建并提交真实报销,由其真实直属主管马建勇审批,再进入王芝玲负责的财务处理链路。
## Background
- 杨俊已存在于管理员导出的企业微信通讯录,因此具备企微成员账号;生产只读体检已确认他尚无 OA 员工记录和企微绑定。
- 上一轮通讯录 apply 只包含两名财务职责目标,明确没有把其余四名成员送入生产输入。
- 报销是当前急需的真实业务,不等待站内通知、入职向导、审批流简化或月度报表等后续产品项。
- 生产写入前需同时确认自动异机备份 timer 的构建合同未因最近部署发生漂移。
- 通讯录单人子集唯一、active、企业邮箱完整、手机号缺失但满足现有 nullable 合同。
- 当前有 37 个 active 费用类型和 1 个 active 报销工作流定义;active 法体为 0,但代码确认报销创建/提交不读取法体,法体不是本次硬门禁。
- 当前自动备份 daily/watchdog/drill-reminder 和日常巡检 service 的 BUILD_SHA 均与活动 server 不一致;最近一次备份成功发生在该漂移被今晚任务触发之前,不能视为今晚安全。
- 管理员导出的通讯录中已有“测试员工”和“测试主管”两个专用企微测试成员;二者均有唯一账号、部门及联系方式且状态为已激活,但生产 OA 尚无姓名包含“测试”的员工或绑定。
- 受控测试发现生产 `OCR_PROVIDER=mock` 且腾讯 OCR 凭证未配置;合成 PDF 被写入虚构的 Mock 发票字段并直接标记可用。该行为可用于测试,但不得作为杨俊真实发票的正式识别结果。
- 生产已将 `OCR_PROVIDER` 切换为 `off` 并重建实际承载后台任务的 server;复验确认票据不进入 OCR 队列、业务字段为空且仍可绑定和提交。`ocr_provider` 因既有非空 schema 默认值仍记录为 `tencent`,但 OCR 尝试数和请求/完成时间均为空;该历史字段语义不作为“已调用腾讯 OCR”的证据。
## Requirements
- 生产只读体检必须一次性核对:OA 员工、企微绑定、active 状态、角色、直属主管、主管 active/审批权限、可用费用类型、报销功能开关和审批定义;法体仅记录现状,不作为报销阻断项。
- 同时比较自动备份 systemd unit 中烤入的 BUILD_SHA 与活动 server 构建标识;只输出是否一致,不输出环境文件或宿主路径。
- 若 OA 账号缺失,复用现有通讯录 stdin 路径只筛选目标员工,先 dry-run,另行获批后 apply;初始角色严格为 `employee`,其余通讯录成员不进入输入。
- 通讯录导入只允许把企微管理员导出的成员资料写入 OA 并建立身份绑定;OA 不调用企微成员 create/update/disable,不反向修改企微姓名、部门、主管、手机号或邮箱。企微成员资料由本人在企微或企微管理员在管理后台维护。
- 将杨俊的 `direct_manager_id` 设置为用户已确认的真实直属主管马建勇;写入前仍需只读确认马建勇为不同、active 且具有 `expense.approve` 权限的 OA 员工,不得为了报销绕过审批或扩宽角色。
- 若费用类型或报销工作流缺失,只补足完成报销所需的最小主数据;不为了本次报销新建法体,不顺带整理全部组织架构或导入其余员工。
- 杨俊写入后只做账号、绑定、主管解析、费用类型和提交流程的安全验证;杨俊本人名下不创建测试报销、测试发票或测试付款数据。
- 允许从同一份管理员通讯录中仅筛选既有的“测试员工”和“测试主管”两个企微测试成员,先 dry-run 再 apply;不得新建企微成员,不得把其他真实成员带入输入。
- 两名测试成员进入 OA 后,“测试员工”角色严格为 `employee`,“测试主管”角色严格为 `employee + manager`;测试员工的 OA 直属主管仅指向测试主管。不得授予 `finance`、`finance_mgr`、`administration` 或 `admin`,不得借测试查看真实薪资、银行资料或系统管理数据。
- 每次受控验证最多创建一条明确标记“系统测试、非真实业务、禁止付款”的测试报销和一张不含真实票据、银行或个人资料的安全测试 PDF;金额使用最小测试值,测试单不得审批通过或进入财务付款链路。配置复验或固定门禁诊断需要重跑时,每批都必须独立退回、删除报销并归档票据。
- 受控测试必须覆盖:两个测试成员分别通过企微登录、申请人创建/编辑报销、上传安全 PDF、处理 OCR 失败分支(如发生)、绑定票据、提交、主管待办可见及执行“退回”。退回后申请人删除 draft 报销,释放后的测试票据手工归档。
- 测试结束后撤销两个测试成员的 OA 会话并停用其 OA 账号;仅停用 OA,不修改或停用企微中的两个测试成员。后续需要复测时可通过现有 OA 接口重新启用。
- 杨俊上传真实发票前必须消除生产 mock OCR:要么配置并验证真实腾讯 OCR,要么显式关闭 mock 并采用人工录入/核对方案;不得把 Mock 字段展示为真实识别结果。
- 真实报销提交前至少需要一条费用明细和一张已绑定、状态有效的票据;这由员工在真实业务中提供,不用合成数据代替。
- 最近部署后必须重装自动备份三项 service/timer 和日常巡检 timer,使 unit BUILD_SHA 与活动 server 一致;重装后手工触发一次真实自动异机备份,确认源端成功且 ARM 恢复点新鲜,再允许写入杨俊账号或主管关系。
- 第一笔真实业务由员工本人正常发起,目标链路为“员工提交 → 真实主管审批 → 财务处理”;付款批次仍遵守既有 maker-checker。
- 当前小团队取消报销金额分层,所有金额统一按“直属主管 → finance”审批;不得保留无人持有的 `finance_mgr` 节点,也不得因马建勇同时是主管和 CEO 而让同一人重复审批。
- 真实首笔报销暴露审批可读性阻断:待办列表卡片能够进入审批任务详情,但任务详情只显示单号、申请人和金额摘要,没有通往费用明细、发票和附件的业务详情入口。移动端必须在审批任务详情区提供明确的“查看完整报销单”入口,复用既有 `/mobile/reimbursements/:id` 路由和 `state.from` 回跳约定;PC 审批任务详情同步提供报销单入口。
- 报销详情当前把后端 ISO UTC 时间直接截取为字符串,附件和审批记录会在中国时区显示早 8 小时;报销链路的日期时间必须复用共享格式化能力并固定按 `Asia/Shanghai` 展示,不修改数据库时间或 API ISO 合同。
- PC 与移动报销详情应按 KISS 原则收敛为四个稳定内容块:“单头概况 → 费用明细 → 报销材料 → 处理进度”。差旅事由、地点和起止日期并入单头,不保留独立差旅卡;项目/差旅申请关联、真实风险和付款事实仅在存在时作为相邻行或节点次级信息显示。页面纯纵向滚动,不增加 Tab、折叠或抽屉,同时保留编辑态必要控件、风险提示和审计信息。
- 生产真机验收确认企微 WebView 可继续运行部署前的旧前端包:公网资源已包含“查看完整报销单”,但存活页面仍只显示报销日期。页面从后台恢复到前台时必须用现有前后端构建标识做一次轻量比对;不一致时只显示用户可控的“发现新版本,点击刷新”,点击后清理旧 PWA 缓存并重载,不自动打断当前页面操作,不扩展为完整更新管理系统。
- 真实审批继续暴露材料查看阻断:PC 能打开普通附件,但 PC 与移动报销详情均没有发票原件入口,企微 WebView 中异步 `window.open(blob:)` 只下载附件而不能预览。企业微信自建应用内必须优先使用官方 `ww.previewFile` / `wx.previewFile` 原生能力查看 PDF;普通 PC 浏览器保留浏览器预览,两端保持“查看原件”的业务能力一致。
- 生产验收发现移动报销列表固定请求 `scope=own`,导致已有 `expense.view_all` 的管理员无法在企微工作台找到杨俊的报销单并代为验证原生预览。移动端必须与 PC 的范围能力对齐:仅对 `expense.view_all` 用户显示“我的 / 全部”切换,默认仍为“我的”,选择“全部”时复用现有 `scope=all` API;普通员工不显示范围切换,不修改后端权限模型。
- 企业微信原生预览使用准确的文件字节数、带扩展名的文件名和短时签名 URL。原生层不会携带 OA Bearer token 或可靠复用 WebView Cookie,因此附件可以在当前任务内增加仅用于 `previewFile` 的短时 bearer URL 例外;该 URL 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
- 生产可见性验收进一步确认:费用明细内再次展示并预览关联发票仍然重复,审批人希望一次进入后连续查看本单全部材料。PC 与移动端应统一采用“明细只讲费用、材料一次通览”:费用明细不再渲染发票摘要或原件入口;发票和补充附件仍保留各自后台类型、关联、权限和审计,但只在单一“报销材料”区汇总,并提供一个“查看全部材料”动作。
- 企业微信官方 `previewFile({ url, name, size })` 一次只接收一个文件。为满足企微与 PC 一次进入、连续查看全部 PDF/图片,本任务将整单材料按稳定顺序派生为一个短时合并 PDF:PDF 原页全部复制,图片各占一页;企微调用一次 `previewFile`,PC 交给浏览器内置 PDF 阅读器。合并件仅供审批核对,不替代原件;任一材料加密、损坏、不支持、超限或合并失败时整包失败并回退逐份原件,绝不静默跳过材料或 PDF 页。
- 生产只读核查确认当前真实报销的 3 张票均已由腾讯 OCR 成功处理:号码、日期和金额齐全,无 OCR 错误;2 张为电子火车票,1 张为数电发票。三者按票种均无传统发票代码,电子火车票也没有销售方字段。页面显示“无代码 / 未识别销售方”属于票种展示错误,不得通过重新调用 OCR、伪造代码或把其他主体字段冒充销售方来补值。报销详情 API 应暴露已持久化 OCR 结果中的安全票种分类,前端按票种显示“电子火车票”或“数电发票”、号码、日期、金额及适用主体字段。
- 生产验收发现移动端“查看全部材料”被误放在“报销明细”区,必须归位到“报销材料”区;PC 已在正确区域,不重构其层级。
- 生产只读计时确认当前图片包串行处理约 1.8 秒,且火车票栅格图在企微中视觉上近似空白。新主路径不得继续现场栅格化 PDF;合并由 Node `worker_threads` 执行并设置材料数、源文件字节、总页数、输出大小和超时上限,避免阻塞单一 server 的事件循环。不启用 Compose worker profile,不引入 PDF.js。
- 合并产物必须可重复生成:材料查询使用显式排序键,缓存键包含报销单 ID、有序材料集合哈希与合并器版本;PDF 元数据和保存选项固定。同样输入独立生成两次必须字节完全相同。签发返回的 `fileSize` 与实际 GET 返回的缓存字节严格一致;缓存缺失或进程重启不能临时拼出另一份字节冒充原签发产物,只能拒绝并让客户端重新签发或降级原件。
- 当前真实报销为“空白报销”创建且未关联差旅申请,现有数据模型没有独立的出差事由、地点和起止日期,因此详情页无法显示完整差旅概要。轻量差旅报销必须直接持久化这四项可选业务字段;存在任一差旅费用明细时,草稿/驳回态提交前必须补齐。既有审批中单据不自动伪造或回填地点;可以只读显示报销说明和明细日期的明确降级值,需完整补齐时由主管退回后申请人修改。
- KISS 复核确认只读审批人仍需要知道本单有哪些原始材料。PC 与移动端“报销材料”区必须始终显示有序材料列表,整行点击只预览该份原件;“查看材料合并预览”保留为列表上方的一次通览动作。只读审批人不得看到移除、删除或票据池管理动作,费用明细仍不重复展示发票。
- 草稿/驳回报销出现差旅类明细且四项差旅概要不完整时,PC 与移动端应直接显示带标签的填写表单,不先展示四个“未填写”再要求用户点击编辑;概要完整后恢复紧凑只读摘要并保留编辑入口。不新增“普通/差旅”选择,不改变现有字段、差旅判定、提交校验或差旅申请关联规则。
- “处理进度”必须使用移动端友好的纵向时间轴。申请提交、付款批次复核、付款与回单是固定业务阶段;中间审批段按当前 workflow 实例的真实任务顺序逐项展开。当前生产规则仍自然形成“申请提交、主管审批、财务复核、付款批次复核、付款与回单”五阶段,但未来新增或重复角色审批节点时不得丢失、覆盖或错误提前显示付款。节点名使用职责而非序号;已完成节点显示实际处理人/时间/结果,当前节点显示实际待办人(可取得时),未来节点不得预填姓名。退回、驳回和有内容的意见必须展开,普通已完成节点压成一行。
- 处理进度是跨状态机只读投影,不把付款批次塞入 workflow。服务端从 workflow 快照/任务/历史、报销付款字段、当前关联付款批次和回单计数生成安全 DTO;已发生/当前审批逐条使用真实 task,尚未创建的未来审批按提交快照补 pending 占位,workflow 实例存在但任务尚未落库时也按快照展示,不因延迟建任务而出现空白;仅在 workflow 整体缺失时使用主管/财务默认占位。任一来源缺失时仅把对应下游节点降级为“待处理”,前面已完成节点继续返回。审批节点标题按“非序号化 step name → assignee role/快照规则职责映射 → 审批”降级,永远不向界面显示原始 step ID、角色码或“步骤 N”。付款批次为共享对象,节点文案必须写“所在付款批次…”,不能暗示单张报销自身再次被审批。
- PC 报销详情必须提供位于标题左上方、来源感知且可预测的返回入口:从带筛选的报销列表进入时返回原列表 URL,从审批任务进入时返回原审批任务;刷新、收藏或非法来源降级到报销列表。返回入口不得继续混在提交、催办、打印或“更多”等业务动作中,来源只接受受信任的 OA 内部报销列表或审批任务路径。
- PC 与移动报销详情的人民币金额必须复用 `client/src/lib/displayFormat.ts` 中的共享格式化函数,统一显示货币符号、千分位和两位小数;缺失或非法金额显示中性占位,不把缺失值伪装成 0 元。
- 移动草稿详情的“添加发票”只保留在“报销材料”区;底部固定动作栏只保留“提交审批”。不删除发票上传/选择能力,不改变提交前检查、材料权限或审批状态机。
- 旧 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
本轮范围只包括:修复生产 automatic-backup/WARCH3 timer 的构建合同并取得新鲜异机恢复点;从通讯录导入杨俊及两个既有企微测试成员;设置各自真实或测试主管关系;用两个测试成员完成一条可清理的生产报销体验验收;清理测试业务数据并停用测试 OA 账号;最后由杨俊本人使用真实数据发起报销;修复真实审批任务详情到完整报销单的移动端和 PC 入口。所有真实报销内容由杨俊本人提供和提交,所有真实审批与财务处理由对应真实人员完成。
## Acceptance Criteria
- [x] 只读体检给出脱敏、可执行的缺口清单,并确认 timer BUILD_SHA 是否与活动构建一致。
- [x] 目标员工在 OA 中为 active,企微绑定唯一,初始业务角色不超过 `employee`。
- [x] 至少有一个 active 费用类型可用于填单;法体缺失已确认不阻断当前报销创建/提交。
- [x] `direct_manager_id` 能解析到真实、active 且具有 `expense.approve` 权限的不同员工。
- [x] 报销相关功能开关和工作流定义可用;无需差旅申请也能创建报销。
- [x] 自动备份三项 timer 与日常巡检 timer 的 BUILD_SHA 均与活动 server 一致,且 timer 保持 enabled/active。
- [x] timer 修复后生成一份新的成功异机批次,ARM 侧完整性/新鲜度检查通过;该门禁先于任何杨俊生产数据写入。
- [ ] 目标员工完成一次真实企微登录并能看到报销入口;主管能看到相应审批入口。
- [ ] 两个既有企微测试成员分别成功通过企微登录 OA;没有创建新的企微成员。
- [x] 两个测试成员均通过普通浏览器账号密码登录并完成首次改密;企微 SSO 仍保留为人工确认项。
- [x] 测试员工仅有 `employee`,测试主管仅有 `employee + manager`,测试主管能看到测试员工提交的待办且执行“退回”。
- [x] 每次受控验证最多产生一条带明确测试标识的报销和一张安全测试 PDF;所有批次均未审批通过、未进入财务或付款链路,并已逐批清理。
- [x] 退回后的测试报销已删除,测试票据已解绑并归档,两个测试 OA 账号已停用且会话已撤销;企微测试成员未被修改或停用。
- [x] 不持久化通讯录筛选文件,不导入杨俊及两个测试成员之外的其他成员,不产生测试付款。
- [x] 生产已显式关闭 mock OCR;`off` 路径已用测试账号复验,空发票元数据仍可绑定、提交、主管退回和清理,真实使用暂由财务查看 PDF 原件并人工核对。
- [x] 本轮没有调用企微成员写接口;企微成员资料未被 OA 反向修改。
- [x] 生产审批规则已收敛为唯一全金额规则,499.99、500、5000 三个边界均解析为 `department_manager → finance`,且没有 CEO 重复节点或 `finance_mgr` 空节点。
- [x] 移动审批任务详情中的报销摘要区提供明确的“查看完整报销单”入口,进入现有移动报销详情并能通过既有 `state.from` 返回原审批任务。
- [x] PC 审批任务详情提供对应的“查看报销单”入口;两端均不修改待办列表卡片的既有点击行为,不修改审批状态机、权限矩阵或后端数据合同。
- [x] PC、移动报销详情和 PC 审批任务详情中的 ISO 时间按 `Asia/Shanghai` 正确展示,UTC 样例不再直接显示原始小时;数据库与 API 时间合同不变。
- [x] PC 报销详情合并重复概况,附件区不再出现“附件材料/补充附件”双标题;无关联、无预算/风险内容时不显示空辅助区块。
- [x] 移动报销详情顶部不重复状态和教学文案;无实际操作时不显示仅含“当前状态不可编辑”的操作卡,编辑态上传与提交能力保持可用。
- [x] 已登录页面从后台恢复到前台且前后端构建不一致时显示明确刷新提示;构建一致、检查失败或开发构建时不误提示,刷新动作复用缓存清理后重载能力。
- [x] 移动企微报销详情中的发票和 PDF/图片附件均提供“查看原件”,通过官方 JS-SDK 原生预览打开;调用参数包含准确 `size` 和带扩展名的 `name`,不依赖异步弹窗或 WebView 原生 PDF 渲染。
- [x] PC 报销详情中的发票和附件提供对应的“查看原件”,使用短时签名 URL 或现有认证下载能力;两端审批人均只能查看当前报销绑定材料,不能编辑、解绑、删除或获得票据池管理权限。
- [x] 移动报销列表仅对具备 `expense.view_all` 的用户显示“我的 / 全部”切换,默认“我的”;切换“全部”后能通过现有列表进入他人报销详情,普通员工始终只能查看本人列表。
- [x] JS-SDK 签名接口只接受本站允许 origin 的无 fragment 页面 URL,不向客户端、日志或审计暴露 JSAPI ticket、企微 Secret 或 access token;签名失败和非企微环境安全降级。
- [x] 发票/附件预览 URL 的过期、篡改、对象不匹配、缺少下载理由和未授权访问均被拒绝;实际文件响应保留审计,用户已确认能在企微查看当前真实报销材料。
- [x] PC 与移动端费用明细不再展示或预览关联发票;`linkedItemId` 数据关系继续保留,仅不在费用明细 UI 重复呈现。
- [x] PC 与移动端单一“报销材料”区显示发票与补充附件计数,并提供一个“查看材料合并预览”动作;企微调用一次原生 `previewFile`,PC 用浏览器内置阅读器打开同一份合并 PDF。
- [x] 合并预览只包含当前有权查看的报销所绑定发票与补充附件,按“发票 → 补充附件、各自创建时间再按 ID、PDF 内原页序”生成;多页 PDF 不漏页,图片各占一页,短时授权、审计、全部上限和整包失败降级均有验证。
- [x] 票据摘要按 OCR `documentCategory` 展示正确票种与适用字段;数电发票和电子火车票不再出现误导性的“无代码”,电子火车票不再出现“未识别销售方”。当前真实票据不重跑 OCR、不改原始识别数据。
- [x] 移动端材料合并预览动作仅出现在“报销材料”区,不占用“报销明细”的主操作位。
- [x] 合并在 `worker_threads` 中受限执行;同样输入生成两次字节完全相同,签发 `fileSize` 等于 GET 实际字节,缓存丢失不返回漂移产物。
- [x] 用无业务数据的多页 PDF 与 JPG/PNG/WebP 合成样本验证页数、顺序、25 页边界和企微一次 `previewFile`;加密、损坏或不可复制 PDF 必须整包失败并回退逐份原件。
- [x] 存在差旅费用明细时,PC/移动详情首部均显示“出差事由、出差地点、起止日期”的轻量概要;草稿/驳回态可编辑且提交校验完整,审批中历史单不伪造缺失字段。
- [x] PC 与移动端只读报销详情始终显示有序材料列表;列表上方保留合并预览,点击单行仅预览对应原件,审批人没有编辑/解绑/删除动作。
- [x] 草稿/驳回差旅报销的概要不完整时直接进入四字段填写态;完整后显示紧凑摘要与编辑入口,普通报销不显示差旅字段。
- [x] PC 与移动报销详情只保留四个稳定内容块;差旅概要并入单头,关联、风险、付款和操作均按数据/权限条件呈现,不形成永久空版块,不增加 Tab 或折叠。
- [x] “处理进度”按五阶段纵向显示,不出现“第 N 步”;当前责任、实际处理人、时间、结果及必要意见清楚,未来节点无姓名,付款批次共享语义准确。
- [x] workflow、付款批次或回单任一来源缺失时进度 DTO 和页面均安全降级,已知节点不丢失;批次 draft/reviewed/executed/failed/cancelled 与回单有/无均有合成回归。
- [x] workflow 中第三个未知职责节点和多个同角色节点均按真实顺序完整显示且互不覆盖;实例存在但任务为空时审批段仍显示安全占位,前端不假设固定节点数。
- [x] PC 从报销列表或审批任务进入报销详情后,标题左上方显示明确的来源返回入口;返回可恢复列表查询参数或原审批任务,直接打开详情则安全回报销列表,业务动作区不再重复显示“报销列表”。
- [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
- 不修改 CEO 本人无主管报销限制,不通过授予 `finance_mgr` 或管理员代批绕过。
- 不重构报销状态机、审批引擎、企微 OAuth 或权限模型。
- 不从 OA 创建、更新或停用企微成员;需要修改企微成员资料时,由本人或企微管理员在企微侧处理。
- 不提前实现 R63–R66,不扩大为全员入职或组织架构治理。
- 不在未获单独批准时执行通讯录 apply、主管/法体写入、生产报销或付款操作。
- 不使用真实发票、真实金额、真实银行资料或真实员工个人资料作为测试材料;不让测试单进入“通过/待财务/付款”状态。
- 不把手机号缺失视为导入阻断项;该员工使用企微或企业邮箱登录,后续按需补手机号。
- 不在本轮顺带修复请假、采购、差旅等其他审批类型的同类“业务单据详情入口”缺口;将其记录为后续统一审批中心体验任务候选。
## Test Plan
- Trellis task/governance、敏感扫描、流程与文档门禁、`git diff --check`。
- 通讯录聚焦 dry-run 仅输出人数、计划、冲突、跳过和失败计数。
- 生产只读 SQL/Prisma 只返回布尔值和计数,不输出手机号、邮箱、企微 userid、完整账号或原始 payload。
- 使用既有报销 workflow/权限聚焦回归验证主管解析和 `expense.approve`,不写生产业务单据。
- 前端运行 `cd client && npm run typecheck && npm run build`,并用聚焦 Playwright 覆盖:移动审批任务详情进入完整报销单、返回原任务后审批动作仍可见,以及 PC 审批任务详情进入报销单。
- 用合成 UTC 时间在聚焦 Playwright 中验证附件时间转换为上海时间,并验证 PC/移动 KISS 信息层级、编辑态关键入口和无动作只读态;不读取生产真实报销内容。
- 用合成 `/ready` 响应验证前台恢复时的构建不一致提示、同版本静默和检查失败静默;不依赖生产真机数据。
- 用合成企微 ticket、签名 URL 和 PDF/图片元数据验证:JS-SDK URL 签名、origin allowlist、原生 `previewFile` 参数、PC fallback、审批人对象级只读授权、附件理由门禁、短时 token 过期/篡改拒绝和实际查看审计;不得使用真实发票或附件。
- 用合成管理员与普通员工权限验证移动报销范围:管理员默认请求 `scope=own`,切换后请求 `scope=all` 并显示他人报销;普通员工不显示“全部”且始终请求 `scope=own`。
- 用合成普通增值税发票、数电发票、电子火车票、多页 PDF 与 JPG/PNG/WebP 补充附件验证 PC/移动一致性:费用明细无发票预览;票种标题不虚构缺失字段;统一材料区计数正确;合并 PDF 包含全部材料并保持顺序;异常/加密 PDF、超限和合并失败可安全降级;编辑态管理动作不回归。
- 用合成申请人、审批人和管理员权限覆盖 PC/移动材料区:列表始终可见且顺序稳定,语义化整行按钮可单份预览,合并预览仍只调用一次,只有可编辑参与人看到移除/删除动作。
- 用合成普通与差旅草稿覆盖 PC/移动概要:无差旅明细时不显示;差旅概要不完整时直接显示四个有标签输入框;保存中、成功和失败有反馈;完整后回到摘要态。
- 后端用合成 workflow task/history、付款批次 item 和回单计数覆盖五阶段只读 DTO:当前/完成/未来节点、退回/驳回意见、maker-checker、共享批次措辞、每个来源单独缺失和权限安全摘要;不写生产业务数据。
- 前端用同一 DTO 覆盖 PC/企微纵向时间轴:已完成节点单行、当前/异常节点展开、无“第 N 步”、未来无姓名、付款批次共享措辞和四块式纯滚动布局。
@@ -0,0 +1,94 @@
# 国内报销详情页 KISS 调研
## 1. 调研问题
面向 10 人以下国内科技公司,报销详情页是否需要把概况、差旅概要、费用明细、材料、关联事项、风险、审批、付款和操作拆成多个常驻区域;如果不需要,最小但完整的信息结构是什么。
## 2. 公开资料结论
- 合思公开产品资料覆盖申请、消费、报销、对账、记账、归档、报表,并强调票据识别、费用可追踪和审核;这些是全链路能力域,不等于单据详情页需要常驻同等数量的版块。
- 汇联易公开产品资料强调费用管理、合规规则、发票管理、智能审核、会计与流程能力;其复杂预算、风险和核算能力面向中大型企业,不应直接成为本项目小团队详情页的默认结构。
- 每刻报销公开产品资料强调出差申请、费用、发票、审核、支付和归档,并明确其相对传统 OA 的优势来自预算、智能审核、ERP 和银企集成;本项目当前没有这些真实配置或集成,不能为了“像费控系统”而展示空区域。
- 人人都是产品经理的费控设计案例把差旅移动流程概括为事项申请、发票采集、场景报销、领导审批和财务审核,并指出不同费用类型应使用动态单据模板和附件清单。可复用原则是“按业务事实动态展示”,不是固定堆叠所有能力域。
## 3. 项目现状
- 移动报销详情当前至少有:报销概况、独立差旅概要、报销明细、报销材料、审批、操作。
- PC 详情除上述主线外,还可能出现关联事项、预算与审核提示、付款状态和更多操作;多数已按数据条件隐藏,但差旅概要仍形成独立卡片。
- 当前公司费用标准和预算没有形成真实常用数据;风险区没有内容时本来就不显示。审批动作在审批任务页,普通业务详情不应复制审批动作。
- 当前真实单据规模小,适合单页纵向滚动;Tab、折叠和二级抽屉只会增加企微 WebView 的状态与返回验证成本。
## 4. 推荐结构
只保留四个稳定内容块,PC 与移动端信息顺序一致:
1. **单头概况**:单号、状态、申请人、部门、总额、说明;差旅单把事由、地点和起止日期并入同一块,普通报销自然不显示差旅行。
2. **费用明细**:费用类型、发生日期、金额和说明;编辑态在这里保留新增、编辑和删除。
3. **报销材料**:计数与合并预览在上,下面始终显示可单份打开的发票/补充附件列表;只读审批人没有移除或删除动作。
4. **审批与处理记录**:提交、当前节点和历史。付款事实仅在实际存在时作为本块内的处理结果显示,不新建永久空版块。
状态性内容不形成新的常驻区域:
- 真实风险或校验异常以内联提示放到单头或对应明细附近;无风险不显示。
- 项目或差旅申请关联作为单头的一行链接;无关联不显示。
- 草稿/驳回操作继续使用页面底部命令区或移动固定操作条,不算内容块。
- 打印、返回列表等低频动作收进“更多”,不占主阅读流。
## 5. 明确不做
- 不做 Tab、折叠、底部抽屉或左右分页。
- 不保留独立“差旅概要”卡片。
- 不新增常驻预算、合规、风险、财务或付款版块。
- 不把审批按钮搬进报销业务详情,不改变审批任务返回链路。
- 不新增普通/差旅类型,不改变现有数据字段、权限、状态机或 API。
## 6. 来源
- 合思官网,合思费控与全链路产品说明:<https://www.ekuaibao.com/>(访问:2026-08-01)。
- 汇联易官网,全面费用管理产品功能:<https://www.huilianyi.com/products/helios>(访问:2026-08-01)。
- 每刻报销官网,企业差旅及费用管理平台:<https://www.maycur.com/mkbx>(访问:2026-08-01)。
- 人人都是产品经理,《费控报销系统,如何设计?》:<https://www.woshipm.com/pd/5292016.html>(访问:2026-08-01)。
以上公开资料只用于提炼信息与流程共性,不把供应商宣传中的预算、AI、ERP、银企直连或大型集团能力视为本项目需求。
## 7. 审批与付款进度补充调研
### 7.1 国内产品共性
- 企业微信官方“获取审批申请详情”数据合同把审批流程表达为多个审批节点;节点含审批人、节点/审批人状态、操作时间和审批意见,备注另行记录。可复用原则是按节点展示职责、处理人、状态、时间和必要意见,而不是显示“节点 1 / 节点 2”。
- 飞书、钉钉等国内审批产品同样把实例详情、任务/节点和操作记录分开建模。公开资料没有要求统一视觉,但移动端纵向时间轴符合用户既有审批心智,也适合本项目企业微信 WebView。
- 国内费控产品通常把费用审批与支付、回单、归档串成端到端业务链;实现上审批 workflow 与付款对象仍是不同状态机。详情页可以给用户一条连续进度,但不能把付款批次伪造成 workflow 节点。
企业微信官方资料:<https://developer.work.weixin.qq.com/document/path/91983>(访问:2026-08-01)。
### 7.2 本项目真实状态机
- 报销 workflow 快照当前固定为“主管审批 → 财务复核”,最终把报销状态置为 `approved`。
- 付款另由 `PaymentBatch` 承担:财务创建批次(maker)→ 非制单人复核批次(checker)→ 执行付款;执行后报销进入 `paid`,银行回单可随后留存。
- 当前 PC `workflowProgress` 显示“第 N 步”,且只在 workflow pending 时返回;移动端只显示当前节点和历史。报销详情没有付款批次安全摘要,因此还不能诚实展示付款复核、执行和回单状态。
### 7.3 推荐的端到端纵向进度
第四个稳定内容块命名为“处理进度”,使用一条纵向时间轴,按职责显示五个业务阶段:
1. 申请提交
2. 主管审批
3. 财务复核
4. 付款批次复核
5. 付款与回单
显示规则:
- 节点名称使用业务职责,不显示“第 1 步 / 第 2 步”。
- 已处理节点显示实际处理人、时间和结果;当前节点显示实际待办处理人(可取得时)和“处理中”;未来节点只显示职责与“待处理”,不根据当前角色持有人猜姓名。
- 审批意见、退回/驳回原因仅在有内容时显示;退回/驳回原因不得隐藏。
- 付款批次未创建时,付款复核节点显示“待财务创建付款批次”;批次 draft 显示“待非制单人复核”;reviewed 显示“复核通过,待执行付款”;failed/cancelled 如实显示异常或重新处理。
- 报销进入 `paid` 后,最后节点显示付款时间/处理人;回单状态作为该节点的次级状态,不额外增加第六块内容。
- 前端时间轴消费统一的只读进度 DTO;服务端分别读取 workflow 快照/历史、当前 task、报销付款字段和当前关联付款批次摘要,再做安全映射。不得修改 workflow 或 PaymentBatch 状态机。
### 7.4 KISS 边界
- 不画 BPMN、分支图或横向步骤条。
- 不展示内部 step ID、角色码、权限码或状态机枚举。
- 不在报销详情复制审批、付款或回单操作;需要处理时仍跳到当前审批任务或付款批次详情。
- 不硬编码自然人姓名;历史用实际 actor,当前用实际 assignee,未来只写职责。
@@ -0,0 +1,40 @@
# 企业微信自建应用原生文件预览调研
## 结论
企业微信自建应用内可以预览 PDF,不应以普通链接在 WebView 中的下载表现判断企业微信是否具备 PDF 预览能力。官方 JS-SDK 提供 `ww.previewFile` / `wx.previewFile`,兼容企业微信 iOS、Android,由企业微信原生层下载 URL 对应文件并在内置预览器中打开,官方说明支持图片、音频、视频、文档等格式。
本任务应优先验证并接入企业微信原生 `previewFile`,而不是先引入 PDF.js 或服务端转图片。PC 普通浏览器继续使用浏览器预览;两端保持“查看原件”业务能力一致,但底层实现按运行环境分流。
## 官方证据
- 企业微信开发者中心《预览文件》:<https://developer.work.weixin.qq.com/document/path/100623>
- 参数为 `url`、`size`、`name`。
- `size` 必填且必须准确,否则打开失败。
- `name` 应包含正确文件扩展名。
- 原生层会重新请求文件 URL;iOS WKWebView 中的 Cookie 不会自动带给原生请求。
- 官方建议资源 cookie-free;需要鉴权时,在 URL 中携带业务侧短时 access token。
- 腾讯企业微信维护的 JS-SDK:<https://github.com/TencentWecom/wecom-jssdk>
- npm 包 `@wecom/jssdk` 的类型定义明确标注 `previewFile` 兼容 WeCom iOS、Android。
- SDK 将 `previewFile` 调用传递给企业微信原生 `WeixinJSBridge`。
- JS-SDK 总入口:<https://developer.work.weixin.qq.com/document/path/90547>
- 自建应用需完成 JS-SDK 注册和当前页面 URL 的 `config` 签名;官方 SDK README 说明自建应用只需提供 `getConfigSignature`。
## 对 hyb-oa 的影响
1. 当前项目没有 JS-SDK ticket/signature 接口,也未引入 `@wecom/jssdk`,需要补最小自建应用签名链路。
2. 发票已有短时签名文件 URL,可复用其 cookie-free 形态,但报销详情还需返回准确 `fileSize` 和带扩展名的 `originalName`。
3. 报销附件当前仅支持带 OA Bearer token 的 Blob 下载;企业微信原生请求不会携带该请求头,需要增加对象级授权后的短时签名 URL,不能改为公开文件。
4. 签名 URL 必须短时、限定文件与申请人、保留实际打开审计;不得把长期 token、Cookie、Secret 或原始企微凭据放入 URL。
5. 移动企微内调用 `previewFile`;普通 PC 浏览器使用签名 URL 的浏览器预览。非企微移动浏览器可保留明确下载或浏览器预览 fallback。
## 建议验证顺序
1. 先用不含业务数据的安全 PDF,在生产同域受控页面完成 JS-SDK `config + previewFile` 技术探针,验证马建勇实际 Android/iOS 企业微信版本。
2. 探针通过后再接发票短时 URL和附件短时 URL,避免拿真实票据做首轮 SDK 调试。
3. 合成数据覆盖:签名 URL 权限、过期/篡改拒绝、准确 size/name、企微原生调用、PC fallback、审批人只读边界。
4. 生产部署后先用安全 PDF 验收,再让经理查看真实报销材料;材料可见前不得审批。
## 行业实现归纳
企业微信 H5 的常见稳妥实现是“JS-SDK 原生预览 + cookie-free 短时签名 URL + Web fallback”。其关键不在 PDF 渲染库,而在把受保护文件临时转换成原生层可拉取、可过期、可审计的 URL。PDF.js 或服务端转图片适合作为官方原生接口在特定机型失败后的第二级 fallback,不应作为本项目首选路径。
@@ -0,0 +1,37 @@
{
"id": "yang-jun-reimbursement-readiness",
"name": "yang-jun-reimbursement-readiness",
"title": "杨俊报销就绪",
"description": "修复生产 timer 构建合同并取得新鲜异机恢复点,随后最小化导入杨俊并将真实直属主管设置为马建勇,打通本人报销链路。",
"status": "in_progress",
"dev_type": null,
"scope": "production timer contract repair, fresh off-host backup, single-member WeCom directory import, manager assignment, and reimbursement readiness verification",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-27",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"deploy/trialrun/install-automatic-backup-timer.sh",
"deploy/trialrun/install-warch3-ops-timer.sh",
"server/src/scripts/import-wecom-directory-export.js",
"server/src/routes/users.js",
"server/src/routes/reimbursements.js",
"server/src/utils/workflow.js",
".trellis/spec/hyb-oa/reimbursement-invoice-ocr.md",
".trellis/spec/hyb-oa/approval-workflow.md",
".trellis/spec/hyb-oa/wecom-boundary.md",
".trellis/spec/hyb-oa/deployment-trialrun.md"
],
"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,81 @@
# 全员登录可用性设计
## 1. Boundary
本任务在现有企微 OAuth 回调内补充确定性自动绑定,不新增公开认证端点、不读取或记录任何真实密码。普通企微登录只绑定既有 active OA 账户,不创建员工、不修改角色、不写企微通讯录。
## 1.1 WeCom Direct-login Contract
```text
企微 OAuth callback 得到内部 userid
-> 已有唯一 binding:直接创建会话
-> 无 binding:读取该 userid 的企微通讯录资料
-> 企业邮箱精确匹配唯一 active OA 账户
-> 目标账户无 binding:事务内 upsert binding + 脱敏审计 + 创建会话
-> 任何缺失/无匹配/inactive/冲突/API 失败:失败关闭
```
匹配键优先级固定为已有 userid binding,其次为企微返回的规范化企业邮箱。姓名不得参与自动决策;手机号不作为本轮普通登录兜底,避免号码复用或格式漂移扩大身份边界。
## 2. Login Baseline
每个 active 账户必须同时满足:
1. `WeCom SSO ready`:属于当前企微通讯录的员工存在唯一 binding,并由本人完成一次工作台直接登录;首次登录不需要 OA 密码。
2. `password fallback ready`:员工本人已设置并掌握密码;它是企微异常时的独立回退,不是首次 binding 的前置条件。
inactive/archived 账户不进入本轮登录覆盖率分母。
## 3. Administrator Recovery
```text
受控终端 -> 精确定位唯一 active 管理员 -> 静默设置一次性密码
-> 清锁定、撤旧会话、递增 token version、失效旧验证码并留脱敏审计
电脑浏览器 -> 使用一次性密码登录 -> 立即设置本人新密码
手机企微工作台 -> 重新进入并完成 OAuth userid 校验
-> 新版 OAuth 回调用 userid 或企微企业邮箱精确匹配既有 active OA 账户
-> 自动建立唯一 binding 并直接创建会话
-> 再次从工作台进入并验证免登
```
一次性密码只允许由用户在受控终端静默输入,或写入仓库外权限为 `0600` 的临时文件供用户本机读取一次;不得出现在聊天、argv、环境变量、日志或仓库。它只用于管理员账号恢复,不参与企微首次 binding。
若自动匹配缺失或冲突,停止操作并只读核对 userid、企业邮箱和 OA active 账户的唯一映射;不得按姓名猜测、不得用密码登录覆盖 binding。个人设置主动绑定只保留为管理员核实后的显式维护入口。
## 4. Remaining Active Accounts
其余 active 账户已有 binding,但尚无真实登录证据。每名员工先直接从企微工作台进入,验证 binding 和免登是否可用;企微免登通过后再按优先级安排密码回退,不让密码流程阻塞主入口验收。管理员只记录 `password_login_pass`、`wecom_login_pass`、`blocked_reason` 三类脱敏结果,不收集密码、验证码或完整账号。
邮件未送达时才进入兜底:管理员通过现有 `reset-password` 接口生成一次性密码,受控现场只显示一次并当面交付;接口强制首次改密、清除锁定、撤销旧会话、使旧凭据失效并写审计。兜底写入需在实际发生前再次获得批准。
## 5. Failure Matrix
- 邮件验证码未收到:先核对投递状态和企业邮箱可用性;不反复请求导致限流。确认邮件路径不可用后才考虑管理员一次性密码。
- 企微内无法同时看登录页和邮箱:改用电脑浏览器完成忘记密码,手机只收验证码;不要为看邮件关闭正在执行 binding 的企微 WebView。
- 验证码过期/错误:由员工重新请求;不得让管理员代收或索取验证码。
- 页面显示已发送但未看到新邮件:先只读确认是否真正签发新 code、有效期、失败次数和邮件发送失败计数。新签发会使旧 code 失效;不得混用旧邮件,也不得在请求上限内连续点击重发。
- 旧验证码被拒绝:归类为“新签发使旧码失效”,证明失败发生在 `/reset-password` 成功之前;不得据此推断前端缓存或首次改密标记未清除。
- 正确验证码重设成功后仍跳到改密页:才归类为前端状态/缓存问题,并记录跳转 URL、页面和报错;不要与旧码失效混为一类。
- 点击登录页“忘记密码”只进入找回页面,不会签发验证码。只有填写企业邮箱并点击“发送验证码/重新发送验证码”才会请求签发;页面导航次数不能作为验证码请求次数。
- 账号或邮箱锁定:只读确认锁定类别和窗口;使用现有解锁接口前另行批准。
- 企微仍显示 unbound:确认是否在企微内打开、OAuth 回调是否取得内部 userid、现有 binding 是否指向另一账户;不得按姓名猜测绑定。
- binding conflict:失败关闭,保留原映射和审计,单独核实身份后再提出精确修正。
- 邮箱验证码登录成功但必须改密页面要求未知旧密码:说明员工走了 `/email-login` 路径;它不同于会清除首次改密标记的 `/reset-password`,返回忘记密码流程,不绕过旧密码校验。
## 6. Rollback
- 员工自助重设密码后旧密码和旧会话按设计失效,无需回滚到旧密码。
- 新 binding 若经本人验证仍错误,只能在精确确认原/目标映射后使用现有解绑/绑定路径恢复;不得直接删除数据库记录。
- 任一员工阻断不影响其他员工逐人完成,但全员验收保持未完成并记录缺口计数。
## 7. Prevention
新账号从创建/导入进入 active 后,不得只以“有密码哈希/有 binding 行”宣告可用。启用完成必须包含安全凭据交付、首次改密、密码登录和企微成员本人工作台免登四项;缺一项则保持 onboarding 未完成并由管理员跟踪。
## 8. Compatibility And Audit
- 已有 binding 的 3 个账户保持原路径,不额外请求通讯录详情。
- 自动补绑定使用现有 `wecom_identity_bindings` 唯一约束,并在同一事务内二次核对 userid 与员工两侧冲突。
- 新审计动作只记录匹配方式和布尔字段,不记录完整 userid、邮箱或企微原始 payload。
- 未绑定页面不再指示员工输入密码完成正常绑定;错误只显示稳定类别,完整身份资料不得进入 URL、日志或前端状态。
- 生产回填先 dry-run 汇总唯一匹配、无匹配和冲突计数,另行批准后才 apply。
@@ -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,55 @@
# 全员登录可用性实施计划
## 1. Read-only Baseline
- [x] 核对公网 `/ready` 和认证 capabilities;企业邮箱验证码登录、忘记密码均启用。
- [x] 聚合核对 4 个 active 账户的邮箱/手机号、密码状态、锁定、企微 binding 和成功登录类型,不输出个人标识或认证数据。
- [x] 确认唯一管理员缺企微 binding,其余 3 个 active 账户已有 binding;当前企微成功登录证据为 0。
- [x] 确认 3 个账户仍需首次改密且未知随机初始密码无法作为已交付凭据。
## 2. Administrator Self-Recovery
- [x] 用户批准进入实施并确认本人可访问企业邮箱。
- [x] 首次电脑自助请求已签发新的有效验证码;旧验证码失效后被误用 1 次,账户未锁定,服务端未记录邮件发送失败。
- [x] 失败现场已定性并保留:失败发生在 `/reset-password` 成功之前,是第二次签发使旧验证码失效;没有前端缓存跳转证据。
- [x] 执行已批准的管理员 break-glass:精确定位唯一 active 管理员,设置受控一次性密码,清除锁定、撤销旧会话、递增 token version、失效旧验证码并写脱敏审计。
- [x] 独立只读复核通过:必须首次改密、锁定清零、活动会话与活动验证码均为 0,审计存在;公网 `/ready` 返回 200。
- [x] 用户以一次性密码登录并完成强制改密;只读核对首次改密标记已清除、当前仅 1 个活动会话、改密与 break-glass 审计均存在。
- [x] 新版部署后用户从企微工作台重新进入,由 userid/唯一企业邮箱自动补 binding 并直接登录,不输入 OA 密码。
- [x] 首次复测仍显示 unbound;只读诊断确认管理员尚无 binding,近 30 分钟没有来自企微 WebView 的成功或失败密码登录,也没有 bind 审计,说明尚未提交页面下方的账号密码表单。
- [x] 用户再次从企微工作台进入,确认直接到 OA 且不再出现 unbound。
- [x] 只读核对管理员新增密码成功、binding 和企微登录审计,输出仅限布尔值。
## 3. Remaining Active Accounts
- [ ] 为其余 3 名已有 binding 的 active 员工发送不含账号/密码的操作说明,由本人先直接测试企微工作台免登。
- [ ] 每名企微成员从工作台真机进入,验证唯一 binding 与免登;免登通过者不因密码流程而阻塞主入口验收。
- [ ] 企微免登验证后,再逐人完成密码回退和首次改密状态收口;管理员不接触其密码或验证码。
- [ ] 邮件失败者单独登记稳定失败类别;确需管理员一次性密码时先给出目标数量、审计和交付方案并另行获批。
## 4. Verification and Prevention
- [ ] 聚合复核 active 总数、密码登录覆盖、企微 binding 覆盖、企微成功登录覆盖和锁定数;不得输出人员明细。
- [ ] 验证无 binding conflict、无角色变化、无企微通讯录写入、无异常会话残留。
- [x] 曾按用户初次指令将唯一 active `manager` 的直属主管设为 `wangshunli`;用户随后澄清其仅为系统开发/系统管理员,该中间关系需按最终职责口径修正。
- [x] 只读确认唯一 active `manager` 与 `majianyong` CEO 账户,排除自环/环路后原子将 `direct_manager_id` 从 `wangshunli` 修正为 `majianyong`、写脱敏审计,并复核组织链;不改角色、邮箱、密码、binding 或企微通讯录。
- [x] 只读解析手工企微通讯录导出并与生产 OA 对账:6 个 userid/binding 和 6 个企业邮箱全部精确一致,缺失、重复、冲突、未匹配和需修改数均为 0。
- [x] 只读确认 `majianyong` 保持 `ceo` 且无直属主管、`wangshunli` 不新增业务审批角色;生产只有 active `finance`、没有 active `finance_mgr`,CEO 本人报销在后续最小权限修正前保持阻断。
- [x] 更新现有管理员手册/试运行 checklist:active 账号启用必须包含安全凭据交付、首次改密、密码登录和企微本人免登。
- [x] 运行认证聚焦测试、前端 typecheck/build、Trellis task/governance、敏感扫描、文档审计和 `git diff --check`;生产部署后的公网 smoke test 仍待独立批准后执行。
## 4A. Passwordless First WeCom Login
- [x] 抽取只读企微成员资料获取能力:输入 OAuth 已确认的内部 userid,只返回规范化企业邮箱和必要状态,不记录原始 payload。
- [x] 普通企微登录在 binding 缺失时,按 userid/唯一企业邮箱匹配既有 active OA 账户;事务内二次检查双向冲突、建立 binding、写脱敏审计并创建会话。
- [x] 对无匹配、inactive、userid 已绑定他人、账户已绑定其他 userid 和企微 API 失败逐类失败关闭;不按姓名绑定、不创建账户、不改角色。企微邮箱缺失时仅允许 userid 精确匹配。
- [x] 保持已有 binding 快路径不变,避免每次登录都调用通讯录详情接口;密码登录不再消费 pending bind 建立企微映射。
- [x] 修正 unbound 页面文案:不再把账号密码作为首次企微绑定说明,显示稳定的映射缺失/冲突处理指引。
- [x] 增加后端路由与服务测试、前端文案/交互测试;覆盖首次免密自动绑定、已有 binding、负向矩阵和审计脱敏。
- [x] 生成生产存量 dry-run 脱敏汇总;部署和 apply 回填分别取得明确批准后执行。
## 5. Stop Conditions
- 企业邮箱不可达、验证码投递异常或账户锁定:停止该员工操作,不连续请求。
- 企微回调缺 userid、binding conflict 或映射不唯一:停止写入并只读诊断。
- 任何步骤要求在聊天、任务、命令参数或普通日志中暴露密码、验证码、完整账号或 userid:立即停止并改用受控现场交付。
@@ -0,0 +1,112 @@
# 全员登录可用性
## Goal
确保每个 active OA 账户至少有一条实际可验收的登录路径,并为企业微信成员预先建立 userid/企业邮箱到 OA 账户的唯一映射;员工第一次从企微工作台进入即直接登录,不把输入 OA 用户名和密码作为首次绑定步骤。
## Scope
- 当前生产 4 个 active 账户的密码回退与企业微信工作台免登基线。
- 管理员本人优先通过已批准的受控 break-glass 恢复密码,再绑定企微并真机验收;其余 3 个已有 binding 的账户先验证企微免登,再按需完善密码回退。
- 邮件失败时使用现有管理员一次性密码接口作为另行批准的兜底。
- 更新现有账号启用/入职操作门禁,防止后续 active 账户再次只有未知随机密码或未经本人验证的 binding。
- 改造企微登录缺失 binding 时的唯一企业邮箱自动匹配、事务绑定、审计和失败关闭逻辑;同步修正未绑定页面指引。
## What I Already Know
- 管理员本人从企业微信工作台进入时收到“当前企业微信账号尚未绑定”,且没有已知可用密码。
- 当前登录页的未绑定流程要求先使用账号密码登录,再到个人设置完成企业微信绑定;这对不知道随机初始密码的导入账户会形成死锁。
- 现有管理员手册已经规定:全员应能账号密码登录,已绑定员工还应能从企业微信工作台进入。
- OA 已有管理员重置普通账户密码接口:生成一次性密码、强制首次改密、清空锁定、撤销旧会话并写审计。
- OA 也支持企业邮箱验证码登录,但真实邮件投递和边缘 challenge 端点是否当前可用必须现场核对,不能假定为可用兜底。
- 企业微信普通登录只按回调 userid 查询唯一 binding;找不到 active 绑定就进入 `wecom=unbound`,不会按姓名、邮箱或手机号自动猜测账户。
- 生产只读基线为:active 账户 4 个,全部有企业邮箱和密码哈希,3 个有手机号,当前密码/邮箱验证码均无锁定。
- 4 个 active 账户中 3 个处于 `must_change_password=true`,但没有实际交付随机初始密码的证据;仅 1 个账户有历史成功密码登录,邮箱验证码成功登录和企微成功登录均为 0。
- 3 个 active 账户已有企微 binding;唯一 active 管理员正是缺 binding 的账户,且该管理员已有历史密码成功登录、当前无需强制改密但本人已不知道密码。
- 企业微信通讯录本身拥有管理员 userid,但生产只读核对显示 OA 侧没有该管理员的目录同步 userid、成功 HR 主数据映射或 identity binding;当前 OAuth 回调只能拿到 userid,无法仅凭它反推出唯一 OA 账户。
- 现有企微 HR 主数据同步已经能够按 binding、userid/员工编号、手机号或企业邮箱解析员工并在 apply 时写入 binding,但当前普通 `mode=login` 回调只查 binding;binding 缺失时不会利用企微通讯录企业邮箱匹配既有 OA 账户。
- 公网认证能力当前报告企业邮箱验证码登录和忘记密码均已启用。
- `/forgot-password -> /reset-password` 是完整的验证码重设密码路径;正确验证码会设置新密码并清除 `must_change_password`,不是死路。
- 本次实际失败发生在 `/reset-password` 成功之前:第二次签发使第一次验证码失效,用户收到并使用旧验证码后被后端拒绝;未发现“重置成功后被前端缓存再次送回改密页”的现场证据。
- `/email-login/code -> /email-login/verify` 是另一条邮箱验证码登录路径;现有前端在验证成功后仍遵守 `must_change_password` 跳转,而修改密码页强制要求旧密码,因此它对未知随机初始密码账户可能形成死路,但与本次失败无关。
## Requirements (Evolving)
- 登录基线固定为双路径:每个 active 账户都必须具备可实际交付并完成首次改密的账号密码回退;属于当前企业微信通讯录的 active 员工还必须具备唯一 binding 和工作台免登。
- 先以只读方式核对所有 active 账户的状态、可登录标识、密码是否为强制首次改密状态、登录锁定、企业微信 binding、最近成功登录类型和企业邮箱验证码能力;只输出计数和脱敏状态。
- 把“数据库里存在密码哈希”与“员工实际掌握可用凭据”区分开,后者必须由一次真实登录或受控的一次性密码交付证明。
- 管理员本人先通过已批准的受控 break-glass 恢复一条安全登录路径,再完成企业微信绑定和工作台真机登录,不通过聊天、命令参数、环境变量、Trellis、日志或仓库传递密码。
- 其余 3 个已有 binding 的 active 账户先直接验证企业微信工作台免登;只有免登不通或需要独立回退时,才进入密码找回或管理员一次性密码流程。
- 企微成员首次工作台登录不得依赖 OA 密码:已有 userid binding 直接登录;binding 缺失时,仅允许使用企微接口返回的企业邮箱精确匹配唯一 active OA 账户,事务内创建 binding、写审计并继续登录。
- 企业邮箱缺失、无 active OA 匹配、目标账户已绑定另一 userid、userid 已绑定另一账户或企微资料接口失败时必须失败关闭;姓名只用于人工核对,不得作为自动绑定键。
- 自动匹配只允许绑定既有 active OA 账户,不得在普通登录中自动创建员工、修改角色、更新通讯录或覆盖冲突 binding。
- 通讯录/HR 主数据同步应在账号启用前建立 binding;登录时自动补绑定仅作为确定性兜底,不替代入职门禁。
- 对每个 active 账户建立可验收的账号密码回退;属于当前企业微信通讯录的 active 员工还应具备唯一 binding 和工作台免登。
- 当前 4 个 active 账户逐人验收后,把“密码回退已完成 + 企微成员免登已完成”写入新账号启用/入职门禁,避免后续导入账户再次只有未知随机密码。
- 密码重置必须使用现有审计接口或经单独批准的受控 break-glass 路径;生成的一次性密码仅在受控现场显示一次,首次登录立即改密。
- 任何 binding 修正必须先证明目标 OA 员工和企微 userid 唯一对应;不得按姓名模糊匹配,不得修改企业微信通讯录成员。
- 生产 binding、密码、账户状态或认证配置写入前另行给出精确变更清单并取得批准。
- 最终业务职责以用户最新确认口径为准:`majianyong` 是 `ceo`,`wangshunli` 仅承担系统开发和系统管理,不是业务审批人;当前唯一 active `manager` 的 OA 直属主管应为 `majianyong`。
- `majianyong` 本人报销不配置直属主管、不允许自审,目标由 active 非本人 `finance` 审批;当前实现只支持 `finance_mgr` 且生产无持有人,本轮保持失败关闭并记录后续最小权限修正,不修改任何角色、邮箱、密码、binding、审批规则或企微通讯录。
## Decision: Credential Recovery
**Context**:管理员缺企微 binding 且未知密码,忘记密码现场又出现第二封验证码未送达;其余 3 个 active 账户已有企微 binding,但尚无真实企微登录记录。让管理员接触所有员工密码会扩大敏感信息处理面。
**Decision**:管理员先执行已批准的受控 break-glass,随后绑定企微并验证免登;其余 3 人先直接验证企微免登。只有免登不通或需要建立独立密码回退时,员工才使用忘记密码;邮件确实无法送达时,再由管理员使用审计能力生成一次性密码并线下当面交付。
**Consequences**:先验证系统预期主入口,避免让 3 名员工重复经历尚未稳定的邮件找回流程;最终双路径基线不变。任何一次性密码都不得经聊天、任务、邮件正文、命令参数、环境变量或普通日志转发。
## Decision (ADR-lite)
**Context**:仅保证一种登录方式会在企微 binding 漂移或未知随机初始密码时再次形成登录死锁。
**Decision**:采用“双路径基线”。账号密码是每个 active 账户的独立回退;企业微信成员在此基础上必须完成工作台免登。
**Consequences**:需要逐人安全交付一次性密码并完成首次改密,也需要逐人企微真机验收;实施工作略多,但能避免任何单一入口故障把员工锁在 OA 外。
## Acceptance Criteria (Evolving)
- [x] 管理员本人能使用一次性凭据完成首次改密,且凭据未进入聊天、任务材料、日志或仓库。
- [x] 管理员本人能从企业微信工作台直接进入 OA,不再出现未绑定提示。
- [x] 一个“无 binding、企微企业邮箱唯一匹配 active OA 账户”的合成场景能在不输入 OA 密码的情况下自动建立 binding、写审计并直接登录。
- [x] 企业邮箱缺失、无匹配、inactive、userid/binding 冲突等负向场景全部失败关闭,且不创建账户、不改角色、不按姓名绑定。
- [ ] 所有 active 账户均有一次实际账号密码登录验收或等价的安全凭据交付与首次改密证据。
- [ ] 所有属于当前企业微信通讯录的 active 账户均有唯一 binding,并完成本人企微真机登录验收。
- [ ] 不存在 active 账户同时缺少可交付密码回退和可用企微 binding。
- [ ] 所有重置、绑定、解锁、会话撤销均有 OA 审计且未扩角色、未修改企业微信通讯录。
- [x] 当前唯一 active `manager` 的直属主管唯一解析为 active `majianyong`,不存在自环或主管链环路,并有脱敏审计;`wangshunli` 不进入业务审批链。
- [x] `majianyong` 保持 `ceo` 且无直属主管;其本人报销目标为 active 非本人 `finance`,当前 `finance_mgr` 配置缺口未修复前保持阻断且不发生自审。
- [ ] 自动化、Trellis、敏感扫描、生产 `/ready` 和关联认证 smoke 通过后才通知员工测试。
## Definition of Done
- 只读基线、写入前后差异、逐人登录验收和剩余阻断均以脱敏计数记录。
- 相关后端测试、前端 typecheck/build、认证 smoke、Trellis 治理和敏感扫描通过。
- 生产变更具备最小回滚:binding 修正可恢复原映射,密码重置通过 token version 和会话撤销确保旧凭据失效。
- 现有管理员手册和试运行事实源与最终登录基线一致。
## Test Plan
- 生产前后只读聚合:active 总数、必须改密数、锁定数、binding 数和各成功登录类型覆盖数。
- 管理员本人及其余 active 员工分别完成真实密码登录与企业微信工作台免登;人工证据只记录 PASS/BLOCKED 和稳定原因。
- 若发生管理员一次性密码兜底,核对强制改密、旧会话撤销、token version 和审计增量,不记录密码正文。
- 运行认证聚焦后端测试、前端 typecheck/build、生产 `/ready` 与认证 capabilities、公网只读 smoke。
- 运行 Trellis task/governance、敏感扫描、文档审计、流程合同和 `git diff --check`。
## Out of Scope
- 不在 OA 中创建或修改企业微信成员。
- 不把真实密码、完整账号、手机号、邮箱、企微 userid 或认证原始 payload 写入任务证据。
- 不启用自助 OAuth onboarding,不重构认证架构,不放宽登录、绑定或账户管理权限。
- 本轮不修改“邮箱验证码登录后首次改密仍要求旧密码”的认证流程。
- 不启用会自动创建 OA 员工的通用企微 OAuth self-provisioning;本轮普通登录只允许匹配并绑定既有 active OA 账户。
- 不顺带导入剩余通讯录成员、修改角色或业务审批规则;主管字段仅包含用户最终批准的单条 `manager -> majianyong` OA 关系。
## Technical Notes
- 企微登录入口与 binding:`server/src/routes/wecomAuth.js`。
- 账号密码和邮箱验证码:`server/src/routes/auth.js`;普通密码登录不再消费 pending bind 建立企微映射。
- 管理员一次性密码重置:`server/src/routes/users.js`。
- 当前产品事实源:`docs/管理员手册.md` 第 10、12 节。
- 安全边界:`.trellis/spec/hyb-oa/security-privacy.md`、`.trellis/spec/hyb-oa/wecom-boundary.md`。
@@ -0,0 +1,26 @@
{
"id": "all-account-login-readiness",
"name": "all-account-login-readiness",
"title": "全员登录可用性",
"description": "核对并修复 active OA 账户登录路径,实现 userid/唯一企业邮箱预绑定或自动补绑定,保证员工首次从企微工作台进入无需 OA 密码即可直接登录。",
"status": "in_progress",
"dev_type": "operations",
"scope": "authentication",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "用户将首次企微免密登录设为硬性要求:OA 账户须通过 userid 或唯一企业邮箱预绑定/自动补绑定,不允许首次输入 OA 密码。已有 binding 快路径及 userid/企微企业邮箱精确匹配既有 active OA 账户的事务补绑定已实现,本地后端全量测试、前端构建、E2E 与治理验证通过;姓名不作为键,缺失/无匹配/inactive/冲突/API 失败全部关闭,不自动创建账户或改角色。等待生产部署和存量映射 dry-run/apply 的独立批准。",
"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,89 @@
# 技术设计
## 边界
本任务不开发新的 OCR provider,也不开放图片发票或改变审批流。在现有腾讯 provider 内把统一入口从 `VatInvoiceOCR` 改为 `RecognizeGeneralInvoice`,增加票种感知的安全归一化和上传大小门禁,再完成生产配置恢复、真实链路验证、差旅补助费用类型收口及两份既有业务文档更新。
生产配置写入、容器重建、腾讯 API 调用和真实样票上传均属于单独受控步骤;每一步只在前一步通过后继续。
## 配置与运行拓扑
- 生产环境文件继续作为 OCR 配置事实源。
- 凭证采用 OA OCR 专用的最小权限子用户密钥;不使用主账号永久密钥,不从聊天、仓库、日志、命令参数或历史残留复制。
- 用户在现场终端静默输入凭证。自动化只检查 provider、必要字段是否非空、文件权限和配置 smoke,不回显值或长度。
- 当前仅 server 执行 jobs,因此只重建 server,不启动 worker;重建后再次确认只有一个 OCR job executor。
- provider 从 `mock` 直接切换为 `tencent` 只能发生在服务开通、权限、计费状态和密钥均确认后。任一前置失败时保持禁止真实票据上传,不用 mock 代替真实能力。
- 计费只使用 `RecognizeGeneralInvoice` 对应的通用票据识别(高级版)每月 1,000 次专用免费包,不接独立火车票接口。生产固定中国大陆地域;控制台确认免费包已到账、后付费关闭,并配置费用或余额告警。免费包耗尽时服务失败关闭,应用记录额度类可解释终态,不自动付费续用。
### 生产部署根收敛
- 生产只读核验发现,活动 Compose 的工作目录被错误设置成标准部署根下的 `deploy/trialrun` 子目录,导致仓库再次同步到自身子目录,形成重复的 `deploy/trialrun/deploy/trialrun`。活动 server、环境文件和四个 systemd 运维 timer 均已引用该嵌套拓扑,因此它不是可直接删除的闲置副本。
- 本次发布把部署根收敛回 deploy 脚本约定的标准单层根。迁移窗口开始时先暂停相关 timer,将当前活动根原子改名为时间戳回退目录,再创建干净标准根;不在原目录上用 `rsync --delete` 就地清理,以免同时破坏活动配置与回退证据。
- 从回退目录把生产环境文件复制到标准根的预期相对位置,保持所有者和 `0600` 权限,不读取、打印或经本地中转 Secret。数据库和附件继续使用部署根之外的既有宿主目录,不搬迁、不初始化、不重置。
- 使用同一 Compose project 和已批准提交部署 server;数据库容器与数据卷保持原位,不创建 worker。部署后重新安装自动备份 daily/watchdog/drill-reminder 与 WARCH3 timer,使所有 WorkingDirectory、ExecStart 和环境文件引用只指向标准根。
- 验收包括活动 Compose 工作目录不再重复、环境文件唯一受控、server `/ready` 和构建标识正确、provider 为 `tencent`、仅一个 job executor、四个 timer 启用且各自 dry/status 检查通过。旧根作为短期回退保留,本任务不删除;后续删除需在新备份成功且另行批准后执行。
## 腾讯 API 合同
- API:`RecognizeGeneralInvoice`,版本 `2018-11-19`,由当前腾讯 Node SDK 调用。
- PDF 请求:Base64、第一页、关闭多页、切图、坐标、二维码和印章返回。
- 一份 PDF 只允许解析出一张受支持票据;零张、多张、未知或不支持子类型进入明确失败状态。
- 支持子类型按两族归一化:住宿 VAT/电子普通发票;电子发票(铁路电子客票)。
- 质量门禁按 `documentSubtype` 选择必填字段;无代码电子票据不再伪造发票代码。
- `OcrResult.result_json` 仅保存规范化业务字段和安全元数据,不保存 provider 原始响应或乘车人身份字段。
- PDF 原文件上限为 7MiB,使 Base64 后保持在腾讯 10M 上限内;大小错误在上传阶段阻断。
## 验证数据流
1. 用户在腾讯云控制台确认通用票据识别(高级版)每月免费包已到账、中国大陆地域、后付费关闭和费用/余额告警。
2. 用户创建或轮换 OA OCR 专用子用户密钥,并确认 OCR 服务可用。
3. 用户在现场终端把 provider、密钥和地域写入生产受控环境文件。
4. 重建 server,使新配置进入实际运行进程。
5. 运行外部依赖 smoke、运行时 smoke、`/ready`、构建标识和 job 拓扑检查。
6. 经样票单独授权后,通过现有 PDF 发票入口分别上传住宿电子普通发票和铁路电子客票样票。
7. 只记录 provider、状态、尝试/完成证据和脱敏字段命中摘要;不记录票面、完整字段或原始 payload。
8. 正常清理或归档测试业务记录,再允许杨俊提交真实票据。
### 授权样票验证工具边界
- 既有 `check-ocr-provider-samples.mjs` 必须与生产 adapter 使用同一 `RecognizeGeneralInvoice` 入口和按票种必填字段规则,不得继续引用已删除的 VAT-only 导出或把铁路电子客票套入 VAT 五字段门禁。
- 真实 PDF 从本地受控、Git 忽略的样票目录按匿名别名选择,经 SSH 标准输入直接流入生产 server 容器内存;不得把原件、临时副本或 manifest 写入远端磁盘、OA 附件存储、数据库、仓库或报告。
- 每个进程只接收一份 PDF,验证文件签名和 7MiB 上限后最多调用一次 `RecognizeGeneralInvoice`。住宿与铁路样票分两次执行,任一失败立即停止,不自动遍历目录中的其他文件。
- 输出只允许匿名别名、provider、票种、安全传输模式、耗时、字段命中布尔值、缺字段名和稳定错误码;不得输出文件名、原始响应、识别值、人员/企业名称、票号、金额或票面内容。
- live runner 的代码、合成测试和生产部署先通过,再单独取得两次真实 API 调用批准;工具可用不等于真实样票已验证。
- 当铁路票已被腾讯识别但金额缺失时,适配器可生成仅供 live runner 使用的临时字段形态诊断:只包含 `Total`/`Fare` 是否存在、是否为字符串或数字、是否非空及能否被当前金额解析器接受的布尔值。诊断不得携带字段值,必须在持久化白名单处被丢弃,普通 OCR job、缓存和数据库均不得保存。
- 字段形态诊断先用合成 provider 响应证明不输出金额或原始 payload,再部署;真实调用仍受单次授权和“铁路未 PASS 即停止住宿”约束。
## 文档口径
- `docs/员工快速上手.md` 增加一页式差旅报销步骤:火车票和住宿费拆明细,官方 PDF 绑定对应明细,两类费用均附支付截图,金额差异必须说明,提交后进入主管和财务流程。
- `docs/报销发票附件规则.md` 固化支付证明必交、敏感字段遮挡、退款/代付/优惠差异和无截图例外口径。
- OCR 结果仅辅助识别和查重;财务仍核对明细、PDF 原件和个人支付证明。
## 差旅补助与费用类型
- `travel_bus` 已由差旅行程 `transport_type=bus` 自动映射,业务语义是城际“大巴”;将其显示名收口为“大巴车票”,不得复用为市内交通补助。
- `travel_meal` 保留代码并将显示名收口为“伙食补助费”;新增 `travel_local_transport_allowance`,显示名为“市内交通补助费”。生产当前两类既有费用均无历史报销明细,种子脚本按代码幂等更新名称和新增类型。
- 伙食补助费为 100 元/人/天,市内交通补助费为 80 元/人/天,均按自然日包干且不要求发票;直接承担、重复报销和个人延长天数按 PRD 规则处理。
- 当前 `ReimbursementItem` 没有天数或数量字段,`ExpenseStandard.per_unit=day` 也不会在费用控制中乘以天数。因此本轮不创建 100/80 元 block 或 warn 标准,员工分别新增补助明细,以“天数 × 单价”人工算出总额并在说明写明计算式,由主管和财务复核。
- 自动带出差旅起止日、计算符合条件的补助天数以及按天校验属于后续产品能力,不阻塞本次真实报销。
## 报销附件上传与中文文件名
- 生产复现显示 PC 一次发送多个补充附件时触发 `FST_FILES_LIMIT`:全局 multipart 合同为 `files=1`,而报销附件路由、PC/手机页面和既有测试均按多文件设计。测试曾把全局上限改成 5,掩盖了生产差异。
- 保持全局 `files=1`,仅在报销附件 `request.parts()` 调用传入 `limits.files=5` 和既有文件大小限制;捕获 `FST_FILES_LIMIT` 并映射为 `VALID_001 / file=too_many_files`。
- 客户端共享 `MAX_REIMBURSEMENT_ATTACHMENT_FILES=5`,PC 与手机选择后先拦截超限并显示相同提示;API 仍接受数组,单次请求最多 5 个。
- 生产还复现了中文附件打开失败:原实现仅替换 CR/LF/引号,仍把中文直接放进 `filename="..."`,Node 以 `ERR_INVALID_CHAR` 拒绝响应头。
- 把现有采购附件的安全 Content-Disposition 构造能力提炼/扩展为可选 `attachment` 或 `inline` 处置类型,生成安全 ASCII `filename` 与 RFC 5987 UTF-8 `filename*`。报销附件与发票 PDF 使用 `inline`;采购、回单等既有 `attachment` 调用保持不变。
- 不改变文件原名在数据库中的业务展示值,不重写已上传文件;修复部署后既有中文名附件即可正常打开。
## 失败与回退
- adapter 聚焦测试、配置 smoke、腾讯调用或任一真实样票验证失败:停止开放真实票据,不继续制造 pending/retry 数据。
- 若新配置导致运行异常,恢复变更前环境文件的受控副本并重建同一 server;回退后不得使用 mock 处理真实票据。
- 若标准根部署、timer 重装或健康检查失败,停止继续发布,将 Compose 工作目录和 systemd unit 恢复到时间戳回退根并重建原 server;数据库和附件目录不参与回退切换。
- 样票失败记录使用可解释状态和脱敏摘要,不直接修改数据库字段绕过状态机。
## 历史凭证结论
2026 年 5 月的证据能证明真实腾讯 OCR 曾可用,但不能证明当时密钥仍有效或满足当前最小权限。当前生产机、历史容器、同机旧环境文件、开发机环境文件及 shell 历史均无可恢复凭证;数据库/附件备份也不包含环境密钥。因此本任务不设计“从旧部署复制密钥”的路径。
@@ -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,172 @@
# 实施计划
## 1. 外部前置门禁
- [x] 用户确认采用 OA OCR 专用最小权限子用户密钥。
- [x] 用户在腾讯云侧创建该专用子用户和密钥。
- [x] 在实际账号确认通用票据识别(高级版)每月 1,000 次专用免费包已到账,生产配置使用中国大陆地域。
- [x] 在 OCR 控制台设置页确认后付费关闭;额度耗尽时接受 OCR 失败关闭。
- [ ] 配置费用或余额告警作为二次兜底。
- [ ] 确认腾讯 OCR 服务和 `ocr:RecognizeGeneralInvoice` 最小权限可用,不接独立火车票接口。
- [ ] 确认住宿电子普通发票和铁路电子客票各一份 PDF 样票的测试授权及仓库外受控位置;不得向 Codex 或聊天提供票据。
## 2. 腾讯 adapter 修正
- [x] 将现有腾讯调用改为 `RecognizeGeneralInvoice`,固定单页 PDF 和最小返回选项。
- [x] 分别归一化住宿 VAT/电子普通发票和 `ElectronicTrainTicketFull`。
- [x] 按安全票种标识执行必填字段检查;删除代码/号码互相伪回填。
- [x] 不持久化腾讯原始响应、乘车人姓名、证件号或完整票面字段;命中旧版腾讯缓存时按同一白名单压缩。
- [x] 发票上传增加 7MiB OCR 输入上限和明确错误。
- [x] 增加两类票种、无代码、多票/不支持票据、敏感字段、大小门禁和 provider 错误聚焦测试。
## 3. 生产配置
- [x] 对当前生产环境文件制作受控、限权、短期回退副本,不输出内容。
- [x] 由用户在现场终端静默写入 `OCR_PROVIDER=tencent` 及专用凭证;地域设置为中国大陆地域。
- [x] 脱敏检查 provider、凭证非空、环境文件权限和配置合同。
- [x] 仅重建实际执行 jobs 的 server,不启动独立 worker。
### 3.1 生产部署根收敛
- [x] 只读确认活动 Compose、生产环境文件和四个运维 timer 均引用重复嵌套部署根。
- [x] 暂停相关 timer,将活动根原子改名为时间戳回退目录,并建立干净标准单层部署根。
- [x] 把生产环境文件在远端复制到标准相对位置,保持所有者和 `0600` 权限;不回显或经本地传输 Secret。
- [x] 使用 `DEPLOY_DATA_MODE=existing`、既有外置数据库/附件宿主目录和同一 Compose project 发布批准提交;未初始化数据或创建 worker。
- [x] 重新安装 daily、watchdog、drill-reminder 和 WARCH3 timer,确认 unit 不再引用重复目录。
- [x] 验证 `/ready`、构建标识、单一 job executor、四个 timer 状态和一次受控备份/新鲜度检查;旧根仅保留作短期回退,不在本任务删除。
回退点:标准根发布或任一 timer 验收失败,恢复时间戳回退根及原 unit 引用并重建原 server;数据库和附件宿主目录不移动、不重置。
回退点:外部依赖 smoke 前若任一配置门禁失败,恢复变更前环境文件并重建 server;保持真实票据禁止上传。
## 4. 生产与真实能力验证
- [x] 检查公网 `/ready` 和预期构建标识。
- [x] 在 server 内运行现有 external deps 和运行时 smoke,不输出 Secret 或原始响应。
- [x] 确认实际 provider 为 `tencent`,且只有一个 OCR job executor。
- [x] 修正 live 样票脚本:复用 `RecognizeGeneralInvoice`、按票种必填字段和安全持久化白名单,支持单 PDF 标准输入且仅输出脱敏摘要。
- [x] 使用合成 PDF/provider 响应覆盖住宿、铁路、非 PDF、超限、缺字段和原始响应不输出,并完成本地门禁、提交和生产部署。
- [x] 为铁路 `Total`/`Fare` 增加只含存在、类型、非空和可解析布尔值的临时诊断;证明持久化白名单丢弃诊断且 live runner 不输出真实值。
- [x] 完成本地门禁、提交和生产部署后,使用剩余一次授权诊断铁路金额字段;铁路未 PASS 时仍不调用住宿样票。
- [x] 修正铁路空 `Total` 阻断可解析 `Fare` 回退的问题,增加回归测试并完成提交、生产部署与运行身份复核。
- [x] 经新增授权复验铁路票;仅在铁路 PASS 后调用住宿票,总计最多两次腾讯 OCR 调用。
- [x] 经单独样票批准后分别运行住宿电子普通发票和铁路电子客票 live OCR PDF 验收。
- [x] 只保存脱敏状态、耗时、provider 和字段命中摘要;确认不存在 mock 调用证据。
- [x] 探针未写入 OA、数据库或远端样票文件;完成只读残留检查,无需清理业务记录。
回退点:真实调用失败时停止样票和真实票据上传,保留可解释失败摘要;不得改库伪造成功或切回 mock 处理真实票据。
## 5. 文档更新
- [x] 更新 `docs/员工快速上手.md` 的差旅报销一页指南。
- [x] 更新 `docs/报销发票附件规则.md` 的支付截图、实报实销差异、退款/代付和人工复核规则。
- [x] 不新建平行员工指南,不写入真实票据、支付信息、账号或环境路径。
## 5.1 差旅补助规定落地
- [x] 修正 `travel_bus` 显示名为“大巴车票”,修正 `travel_meal` 显示名为“伙食补助费”,新增 `travel_local_transport_allowance` / “市内交通补助费”。
- [x] 增加聚焦测试,锁定城际大巴映射和两项补助费用类型不混用。
- [x] 更新员工指南和报销事实源,写明 100 元/天伙食补助、80 元/天市内交通补助、自然日、包干、直接承担、个人延长和其他费用实报实销规则。
- [x] 明确当前由员工按“天数 × 单价”人工填写总额;不配置会误判多日补助的单条费用标准。
- [x] 重新生成并检查一页版差旅报销 PDF。
## 5.2 真实首笔报销附件阻断修复
- [x] 报销附件路由显式支持单次最多 5 个文件,并稳定映射 `FST_FILES_LIMIT`。
- [x] PC/手机共享最多 5 个文件的选择限制、提示和客户端拦截。
- [x] 报销附件与发票 PDF 使用安全 ASCII fallback + RFC 5987 UTF-8 文件名响应头。
- [x] 测试恢复生产全局 `files=1`,覆盖路由级 5 个成功、6 个失败、中文名及头注入清理。
- [x] 完成后端、前端、流程、文档、敏感内容和 Trellis 门禁;提交后另行取得生产部署批准。
## 2026-07-30 报销附件阻断修复验证
- 生产日志根因已由聚焦回归锁定:PC 多文件请求触发全局 `FST_FILES_LIMIT`,中文原名直接进入响应头触发 `ERR_INVALID_CHAR`;手机并非独立后端链路。
- 报销附件路由保持生产全局 `files=1` 基线并局部覆盖为 5;第 6 个文件返回 `400 VALID_001 / too_many_files`,且附件和审计写入均为 0。
- 报销附件与发票 PDF 已统一使用安全 `inline` Content-Disposition;采购、员工附件和付款回单仍保持默认 `attachment`。
- 聚焦测试 3 个文件/47 项、后端全量 169 个文件/1082 项、Prisma validate、前端 typecheck/build、流程合同、文档审计、Trellis governance、敏感内容、task validate 与 `git diff --check` 均通过;文档审计仅保留 22 个既有未注册文档 warning。
- 本地验证检查点时主修复已提交为 `7341538`,但尚未推送或部署;后续生产结果见下节,PC/手机真实验收仍不得由代码门禁替代。
## 2026-07-30 报销附件修复生产部署
- 主修复 `7341538` 与提示修正 `030cf46` 已推送;生产来源提交为 `030cf46c92bd`,来源、环境契约和远端 Compose 配置门禁通过,未同步或覆盖生产 `.env`。
- 标准构建首次停在 Alpine 基础包下载且尚未执行 init、迁移或服务切换;确认旧服务仍以原构建正常提供 `/ready` 后终止未完成构建,改用同一 Dockerfile、同一构建参数和宿主网络完成镜像构建,再执行受控 init 与 `--no-build` 切换。
- Prisma 62 个迁移均为最新;配置种子保留 3 条既有审批规则,未重置管理员密码。external deps、runtime smoke、公网 `/ready`、12 位构建标识和前端资源扫描均通过。
- 运行拓扑保持单一 server 执行 jobs、无 worker;OCR provider 仍为腾讯。四个自动备份/WARCH3 timer 已刷新到新完整构建并保持 enabled/active。
- 首次主动备份因 automatic-backup unit 仍引用旧构建而被 runtime identity 守卫拒绝;刷新 unit 后重新执行配对备份与新鲜度检查均成功,未放宽守卫或绕过校验。
- 代码部署与公网检查已完成,但真实业务验收仍待杨俊在 PC 端一次上传多个附件并打开中文名附件;该人工验收通过前,不把两个现场问题标记为最终关闭。
## 6. 验证与审查
- [x] 静态核对页面/API 上传格式、明细绑定和员工/财务字段边界。
- [x] 运行腾讯 adapter、OCR service/job 和发票上传聚焦测试。
- [x] `cd server && npm run prisma:validate`
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`(0 个错误;22 个既有未注册文档 warning,本任务未新增)
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `git diff --check`
- [x] 对生产配置、样票证据和文档口径进行只读复核。
- [x] 部署与公网检查均通过后,才通知杨俊可以提交真实差旅报销。
## 2026-07-29 live OCR 验收工具生产部署记录
- 已部署提交 `fa326f9`;公网 `/ready` 与运行容器构建标识一致。
- external deps smoke、runtime smoke 和公网前端资源扫描通过;实际 OCR provider 为 `tencent`。
- 运行拓扑为 server 单一 job executor,未启动 worker。
- 自动备份 daily、watchdog、drill-reminder 与 WARCH3 四个 timer 均为 enabled/active,冻结构建与标准部署根一致,未引用旧重复目录。
- 部署后受控自动备份和新鲜度检查均成功,未调用真实 OCR 样票。
- 真实住宿和铁路样票验收仍等待单独调用授权;在两类样票通过前,不通知员工上传真实票据。
## 2026-07-29 授权样票验收尝试
- 已匿名选择铁路 PDF 并执行最多一次 provider 调用,但外层 shell 在探针非零退出时提前终止,未保留已生成的脱敏 JSON,因此本次不能作为 PASS 或可解释 provider 终态证据。
- 按“第一份未取得明确 PASS 即停止”约束,未调用住宿样票,也未重试铁路样票。
- 空 stdin 捕获自检证明修正后的调用方式可在 provider 调用前保留 `HOLD/sample_empty` 摘要;该自检未消耗 OCR 次数。
- 本次没有创建 OA 发票、附件或数据库记录;真实样票仍未验收通过,员工上传门禁保持关闭。
- 经额外授权后,修正包装器重新验收铁路 PDF,明确得到 `HOLD/sample_pdf_invalid`;失败发生在 provider 调用前,本次未消耗腾讯 OCR 次数。
- 匿名结构预检确认铁路 PDF 的 EOF 后存在非空白尾随数据,不符合现行严格 PDF 终止边界;不截断原件、不放宽安全门禁,需重新取得官方原始电子票据。
- 住宿 PDF 的 PDF 头部、EOF 终止边界和 7 MiB 上限预检均通过,但因铁路票未 PASS,未调用住宿样票 provider。
- 用户另行指定的铁路 PDF 通过同一结构预检,证明上一份 `sample_pdf_invalid` 是旧文件特有的 EOF 尾随数据问题,不是所有铁路 PDF 或校验器普遍失败。
- 新铁路 PDF 经真实腾讯 OCR 识别为单张 `ElectronicTrainTicketFull`,票号、日期和购买方字段命中,但 `totalAmount` 未命中,终态为 `HOLD/missing_required_fields:totalAmount`;未输出或持久化任何识别值、原始响应或票面字段。
- 当前适配器已按腾讯 SDK 合同读取铁路票 `Total` 与 `Fare` 两个金额字段;现有脱敏摘要不能区分 provider 未返回金额与返回值格式不可解析,需在另行批准后增加字段形态布尔诊断。铁路票未 PASS,因此仍未调用住宿样票 provider。
## 2026-07-29 铁路金额字段诊断实现验证
- 聚焦测试 19 项、完整后端 168 个文件/1075 项、Prisma validate、前端 typecheck/build、流程合同、Trellis governance、敏感内容和 diff 门禁通过;仅有既有依赖弃用提示与 22 个既有未注册文档 warning。
- P2 聚合器的后端聚焦测试 94 项及前端门禁通过;其业务流因合成账号首次改密状态失败,live-readiness 因本地旧凭据和部署前构建标识不一致失败。进入 live OCR 步骤时已主动终止,生产最近 10 分钟新增发票和 OCR 尝试记录均为 0。
- 诊断只存在于 provider 返回到 live runner 的进程内对象,持久化 sanitizer 丢弃整个 diagnostics;合成敏感标记未出现在序列化摘要中。
- 诊断提交 `5f2788c` 已部署;公网构建、external deps、runtime smoke、单一 job executor、四个 timer、自动备份和新鲜度检查均通过。
- 最后一次授权调用确认腾讯返回的铁路 `Total` 是空字符串,而 `Fare` 非空且可被当前解析器接受。现有 `info?.Total ?? info?.Fare` 在解析前选择空字符串,阻断了 `Fare` 回退;根因是适配器空字符串回退顺序,不是 PDF、凭证、网络或腾讯票种识别失败。
- 本次仍为 `HOLD/missing_required_fields:totalAmount`,住宿样票未调用;所有既有真实 OCR 调用授权已用尽,修复后复验必须重新取得授权。
- 已将铁路金额改为分别解析 `Total` 与 `Fare` 后回退,并增加“空 `Total` + 可解析 `Fare`”回归测试;聚焦测试 10 项、Prisma validate、流程合同、Trellis governance、敏感内容和 task validate 均通过。
- 已在现有 OCR code-spec 固化同类字段回退规则和测试要求;本轮尚未提交、部署或调用腾讯 OCR。
## 2026-07-29 铁路金额修复与双样票验收
- 修复提交 `665cf4a` 已推送并部署;迁移状态、`/ready`、external deps、runtime smoke 和公网资源扫描通过,公网构建标识一致,生产 provider 为 `tencent`,仅一个 server 执行 jobs,未启动 worker。
- 自动备份 daily、watchdog、drill-reminder 与 WARCH3 四个 timer 已刷新到当前构建并保持 enabled/active;主动自动备份与新鲜度检查均成功。
- 通过标准输入运行的铁路样票明确 `PASS`:票种为铁路电子客票,号码、日期、金额和购买方必填字段命中;空 `Total` 未再阻断可解析 `Fare`,无缺失必填字段。
- 仅在铁路通过后运行住宿样票并明确 `PASS`:票种为数电发票,号码、日期、金额、税额、价税合计、销售方和购买方字段命中,无缺失必填字段。
- 两次授权腾讯 OCR 调用已全部用完。输出仅保留匿名 alias、票种和字段命中布尔值;两次探针均未创建 OA 发票或 OCR 数据,最近 30 分钟对应新增记录为 `0/0`,无需业务数据清理。
## 2026-07-29 差旅补助规定实施验证
- 已把城际大巴、伙食补助和市内交通补助收口为三个独立稳定代码;种子聚焦测试证明按代码幂等更新/创建,不配置 100/80 元单条费用标准。
- 员工指南与报销事实源已写明两项补助单价、自然日、包干、直接承担不重复领取、个人延长不计补助和人工算式要求;单页 PDF 已重新生成并完成视觉检查。
- 聚焦测试 3 个文件/17 项、后端全量 168 个文件/1077 项、Prisma validate、流程合同、文档审计、Trellis governance、敏感内容、task validate、Node 语法和 `git diff --check` 均通过;文档审计仅保留 22 个既有未注册文档 warning。
- 本地实施不等于生产费用类型已变更;生产落地结果见下节记录。
## 2026-07-29 差旅补助规定生产部署
- 提交 `13a3f4b` 已推送并部署;来源校验、环境契约、迁移状态、external deps、runtime smoke、公网 `/ready`、构建标识和前端资源扫描均通过。
- 标准 Compose 构建首次停在 Alpine 基础包下载,旧 server 始终保持可用;终止未完成构建后使用同一 Dockerfile、同一构建参数和宿主网络成功构建,再完成受控切换。
- 为遵守“定向费用类型写入”授权,本次未运行会改写其他主数据的全量 `seed:config` / `seed:trialrun`;通过受限 Prisma 包装器只执行 3 次目标 upsert。
- 生产只读核验确认“大巴车票”“伙食补助费”“市内交通补助费”三个独立费用类型均 enabled,相关 active 费用标准为 0,未引入多日补助误报。
- 运行拓扑为一个 server 执行 jobs、无 worker,OCR provider 为 `tencent`;自动备份 daily/watchdog/drill-reminder 与 WARCH3 四个 timer 已刷新冻结构建并保持 enabled/active。
- 员工指南、真实 OCR 和生产费用类型已对齐,杨俊可以从企业微信工作台进入并按四类明细创建真实差旅报销;实际提交仍由本人完成。
## 风险文件与操作
- 生产环境文件:包含 Secret,只允许用户现场静默输入;不得由补丁、聊天或命令参数传入。
- server 重建:短暂影响 OA;仅操作当前实际 server,保留回退配置。
- 真实样票:包含发票和个人/企业信息;只留在仓库外受控位置。
- 文档:员工指南必须短且与生产费用类型、PDF-only 和附件入口一致,避免把财务付款回单误写成员工材料。
@@ -0,0 +1,99 @@
# 差旅报销指南与腾讯 OCR 正式启用
## Goal
先把生产发票识别从 mock 切换到真实腾讯 OCR 并取得授权样票证据,再让杨俊及后续员工一次看懂差旅报销需要提交什么、分别上传到哪里、差旅补助如何计算,以及财务如何按“明细—发票—个人支付证明”复核。
## Background
- 当前报销单明细包含费用类型、发生日期、金额和说明;发票绑定到具体明细,补充附件属于整张报销单。
- 发票入口只接受 PDF;补充附件接受 PDF、JPG、PNG、WebP,可用于支付截图、订单和情况说明。
- 员工不填写财务付款凭证号、付款日期或付款渠道;这些字段属于审批通过后的财务处理。
- 生产已有 37 个 active 费用类型,其中“火车票”“住宿费”“机票”“打车费”“差旅餐补”各唯一存在;当前 active 费用标准为 0,不会自动执行金额上限。
- 用户已决定:火车票和住宿费均要求员工提交个人支付截图。
- 生产只读核验发现当前运行配置为 `OCR_PROVIDER=mock`,`TENCENT_SECRET_ID` 和 `TENCENT_SECRET_KEY` 均未配置;server 自身运行 jobs,当前没有独立 worker。
- 2026-05-05 的试运行证据证明生产腾讯 OCR 当时曾通过真实 PDF 验证;但当前生产环境、同机历史环境文件、已停止应用容器、开发机环境文件和 shell 历史均没有可恢复的旧腾讯密钥。现有自动备份只覆盖数据库和附件,不覆盖环境密钥,因此不能从备份恢复该凭证。
- 官方 API 和当前实现复核发现:现有腾讯 adapter 调用 `VatInvoiceOCR`,适合住宿增值税/电子普通发票,但不是统一覆盖电子发票(铁路电子客票)的推荐接口。官方 `RecognizeGeneralInvoice` 同时覆盖住宿常见 VAT 子类型和 `ElectronicTrainTicketFull`,当前锁定 SDK 已支持该方法。
- 当前实现会对所有票据强制要求发票代码和销售方,并在代码/号码缺失时互相回填;铁路电子客票及部分数电票会被误判失败或污染查重语义。现有 `raw` 结果还会持久化腾讯完整响应,铁路票返回可能包含乘车人证件信息。
- 腾讯要求文件 Base64 编码后不超过 10M,现有 OA 允许 10MiB 原始 PDF,存在上传成功后 OCR 必然超限的区间。
- 用户决定不接受 `off` 人工模式,要求配置并正式启用真实 OCR;在凭证配置和授权样票验证通过前,杨俊不得上传真实发票。
- 用户已确认采用推荐的凭证方案:在腾讯云创建或轮换 OA OCR 专用最小权限子用户密钥,并由用户在现场终端静默写入生产。
## Scope
- 核对报销明细、PDF 发票、补充附件、支付截图和财务付款记录的实际页面/API边界。
- 更新 `docs/员工快速上手.md`,增加一页可直接照做的差旅报销指南。
- 更新 `docs/报销发票附件规则.md`,固化火车票/住宿费支付证明、实报实销和 OCR 关闭期间人工核对规则。
- 配置并正式启用生产腾讯 OCR,完成配置门禁、受控发布、公网检查和授权 PDF 样票验证。
- 在现有腾讯 provider 内改用通用票据识别高级版,补齐住宿电子普通发票和铁路电子客票的安全归一化、质量门禁、大小限制和测试。
- 固化公司差旅补助规定,补齐不与城际大巴费用混淆的费用类型,并同步员工指南和报销事实源。
- 修复真实首笔报销暴露的 PC 多附件上传与中文文件名打开失败,保持 PC/手机和生产 multipart 合同一致。
## Requirements
- 火车票与住宿费必须拆为两条报销明细;每条分别填写费用类型、发生日期、实际合规金额和事由说明。
- 正式电子票据/发票使用官方 PDF,通过对应明细的“添加发票”入口上传并绑定;截图不得冒充正式发票。
- 火车票和住宿费的个人支付截图通过“附件材料/补充附件”上传,并用文件名或说明明确对应哪条明细。
- 支付截图仅保留商户、金额、时间和支付成功状态;银行卡号、支付账号、订单号等无关敏感字段应遮挡。
- 报销金额以个人实际支付的合规金额为准,原则上不得超过有效发票金额;优惠券、多人合并开票、部分自费或其他差异必须在明细中说明。
- 已退款、公司直接支付或他人承担的金额不得重复报销;确实无法提供支付截图时写情况说明,由财务决定退回或例外接受,不得伪造凭证。
- 腾讯 OCR 识别字段用于匹配、查重和财务复核,但不替代支付真实性、费用合理性或发票法律状态的人工判断;财务仍核对明细金额、PDF 原件和支付证明。
- 腾讯 provider 使用 `RecognizeGeneralInvoice`,每份 PDF 只识别第一页且只接受一张受支持票据;不请求切图、字段坐标、二维码或印章数据。
- 住宿 VAT/电子普通发票与铁路电子客票按各自官方返回结构归一化;铁路电子客票不强制要求不存在的发票代码或销售方,不得用发票号码伪造代码。
- 持久化结果只保留业务需要的规范化字段、安全票种标识和传输模式,不保存腾讯原始响应、乘车人姓名、证件号或完整票面字段。
- 正式 PDF 发票增加不高于 7MiB 的 OCR 输入门禁,避免 Base64 后超过腾讯 10M 限制;超限在上传阶段给出明确提示,不进入重试队列。
- 腾讯云凭证使用最小权限子用户或专用身份,不使用主账号永久密钥;凭证只通过用户现场终端写入生产受控环境文件,不进入聊天、命令参数、日志、仓库或 Trellis。
- 不复用来源、权限或有效性无法确认的历史密钥;优先在腾讯云侧为 OA OCR 创建或轮换专用子用户密钥,并撤销确认不再使用的旧密钥。
- 正式启用前需确认 OCR 服务已开通、调用费用/免费额度和告警边界;无凭证、权限不足、欠费或 API 未开通均不得把 provider 切成 `tencent`。
- 只使用 `RecognizeGeneralInvoice` 的通用票据识别(高级版)每月 1,000 次专用免费额度,不接仅老客户续费的独立火车票接口;生产使用中国大陆地域,必须在实际账号确认免费包到账、关闭后付费并配置费用或余额告警。额度耗尽时允许 OCR 明确失败,不得自动产生费用。
- 当前无独立 worker 且 server 自身运行 jobs,配置变更只重建实际加载配置的 server;不得额外启动第二个 OCR job executor。
- 真实能力必须使用已授权 PDF 样票验证;缺少授权样票只能证明配置齐全,不能宣称真实 OCR 已通过。
- 指南明确审批链:员工提交、直属主管审批、财务复核;财务付款和回单不由员工预填。
- 伙食补助费按 100 元/人/天包干,市内交通补助费按 80 元/人/天包干;两项补助均不要求发票。
- 补助按出差自然日计算,出发日和返回日均计 1 天,当天往返计 1 天;因个人原因延长的天数不计补助。
- 领取伙食补助后个人餐费不得另报;领取市内交通补助后,出差期间公交、地铁、出租车、网约车等不得另报。
- 公司或其他单位已直接承担餐食或车辆费用的,不重复领取相应补助;城际交通、住宿及其他经批准的必要差旅费用凭有效材料实报实销。
- 当前报销明细没有天数字段,两项补助分别作为独立明细人工填写总额和计算式,例如“伙食补助费:3 天 × 100 元”;不得把现有 `per_unit=day` 费用标准直接配置为 100/80 元,因为当前校验不会按天数乘算,会误判多日补助。
- `travel_bus` 保留为城际大巴车票费用;市内交通补助使用独立费用类型,不得共用代码或混同展示。
- 报销补充附件在 PC 与手机端均允许单次选择 1–5 个文件;服务端必须在该路由显式覆盖全局单文件 multipart 上限,第 6 个文件进入稳定的 `VALID_001` 错误,不得退化成 `SYS_001`。
- PC 与手机选择器均显示“单次最多 5 个”,并在请求前拦截超过 5 个文件的选择;客户端限制只改善体验,服务端仍是最终边界。
- 报销附件和发票 PDF 的中文原文件名不得直接进入 HTTP 响应头;`Content-Disposition` 使用安全 ASCII fallback 与 RFC 5987 `filename*`,同时清理控制字符、路径和响应头注入字符。
- 报销附件继续使用 `inline` 语义,发票 PDF 继续支持当前预览/下载流程;修复不得改变附件权限、用途留痕、审计或存储内容。
- 文档不放真实票据、支付截图、账号、订单号、银行信息或完整业务数据。
## Acceptance Criteria
- [x] 员工指南能在一页内回答“交什么、传到哪里、金额怎么填、哪些材料不能混传、提交后谁处理”。
- [x] 指南内容与当前页面、服务端格式限制及生产费用类型一致。
- [x] 业务事实源明确火车票和住宿费支付截图为必交补充材料,并包含遮挡、差异、退款和无截图例外规则。
- [x] 腾讯 adapter 使用 `RecognizeGeneralInvoice`,聚焦测试覆盖住宿电子普通发票、铁路电子客票、无代码票据、多票/不支持票据、大小门禁和敏感原始字段不持久化。
- [ ] 腾讯云控制台确认通用票据识别(高级版)每月 1,000 次免费包已到账、中国大陆地域、后付费关闭和费用/余额告警可用;额度耗尽不产生后付费。
- [x] 生产实际 provider 为 `tencent`,只授予 `ocr:RecognizeGeneralInvoice`,两个凭证字段已配置但不泄露,external deps smoke、`/ready` 和运行 job 拓扑通过。
- [x] 住宿电子普通发票和铁路电子客票各一份已授权 PDF 经真实腾讯 OCR 进入可解释终态,并证明没有调用 mock;样票和原始识别 payload 不进入仓库或聊天。
- [x] 明确 OCR 只辅助字段识别/查重,财务仍执行明细—PDF 原件—支付证明复核。
- [x] 员工指南和报销事实源完整写明两项补助的单价、自然日口径、包干不另报、直接承担不重复领取和个人延长不计补助规则。
- [x] 生产种子数据中“伙食补助费”“市内交通补助费”均为独立 enabled 费用类型,`travel_bus` 仍明确表示城际大巴车票。
- [x] 未配置会把多日补助误判为超标的 100/80 元单条费用标准;指南要求员工按天数人工计算并在说明中写出算式。
- [x] 在真实 OCR 验收完成前不得仅因指南已完成就通知杨俊可以提交。
- [x] 全程不把真实票据、支付截图、完整账号、银行资料、Secret 或原始 payload 写入仓库、任务或聊天。
- [x] PC 与手机均可一次上传 1–5 个报销补充附件;选择第 6 个时客户端提前提示,绕过客户端直调服务端仍返回稳定 `VALID_001` 而非 500。
- [x] 中文文件名的报销附件和发票 PDF 均返回合法 `Content-Disposition`,保留 `inline` 与 UTF-8 文件名语义且无响应头注入。
- [x] 聚焦测试使用与生产一致的全局 `files=1` 基线,证明路由级覆盖有效,并覆盖 5 个成功、6 个失败、中文名和恶意文件名。
## Out of Scope
- 新增第二个 OCR provider、开放图片发票、自动验真或增加人工发票字段。
- 本轮不做通用发票平台或覆盖全部票种;仅支持现有住宿 VAT/电子普通发票和电子发票(铁路电子客票)主路径。
- 修改报销页面、上传格式、审批规则或自动按天计算;本轮只调整差旅相关费用类型名称并新增独立的市内交通补助类型,不配置 100/80 元费用标准。
- 改变全局 multipart 单文件默认值,或放宽发票上传“一份请求一张正式票据”的边界。
- 代替杨俊上传票据、创建报销、提交审批,或代替主管/财务处理。
- 制定覆盖所有费用类别的完整财务制度;本轮只收口首笔差旅的实报实销材料和两项包干补助。
## Test Plan
- 静态核对前后端上传格式、明细绑定、员工/财务字段边界。
- 运行腾讯 adapter 和 OCR job 聚焦单测,覆盖两类票种、缺字段、错误分类、大小限制和敏感字段最小化。
- 生产只读核对费用类型、OCR provider 和必要运行状态,仅输出类别与计数。
- 运行文档注册、敏感内容、流程合同、Trellis governance 和 `git diff --check`。
- 启用真实 OCR 后验证 `/ready`、构建标识、运行 provider=`tencent`、单一 job executor 和配置 smoke。
- 经单独样票批准后运行现有 live OCR 验收,输出只保留状态、耗时和脱敏字段命中摘要,并按正常业务/API 生命周期清理或归档测试记录。
@@ -0,0 +1,88 @@
# 腾讯 OCR API 调研
调研日期:2026-07-28。仅查阅腾讯云官方文档、当前锁定 SDK 和本地代码;未创建密钥、未调用付费 API、未上传票据。
## 官方资料
- 通用票据识别(高级版):https://cloud.tencent.com/document/api/866/90802
- 增值税发票识别:https://cloud.tencent.com/document/api/866/36210
- OCR 控制台访问管理:https://cloud.tencent.com/document/product/866/102912
- 免费额度:https://cloud.tencent.com/document/product/866/35945
- 计费概述:https://cloud.tencent.com/document/product/866/17619
- 混贴票据计费说明:https://cloud.tencent.com/document/product/866/48522
- 错误码计费说明:https://cloud.tencent.com/document/product/866/45470
- 新手常见问题:https://cloud.tencent.com/document/product/866/43818
- 欠费说明:https://cloud.tencent.com/document/product/866/30576
## 结论
### 1. 当前接口不适合作为两类差旅票据的统一入口
当前代码调用 `VatInvoiceOCR`。官方说明明确覆盖增值税专票、普票、电子专票、电子普票和数电普通/专用发票,适合住宿电子普通发票;但官方用于统一识别火车票、电子发票(铁路电子客票)和增值税发票的是 `RecognizeGeneralInvoice`。
`RecognizeGeneralInvoice` 明确返回 `ElectronicTrainTicketFull`,同时覆盖 `VatElectronicCommonInvoice`、`VatElectronicInvoiceFull` 等住宿发票常见子类型。因此本轮应在现有腾讯 provider 内改用该接口,而不是直接给当前 `VatInvoiceOCR` 配密钥。
### 2. 请求合同
- API 版本仍为 `2018-11-19`,请求域名为 `ocr.tencentcloudapi.com`。
- 当前锁定的 Node SDK 已包含 `RecognizeGeneralInvoice`;无需为了该方法先升级依赖。
- PDF 通过 Base64 传输,不需要公开文件 URL。
- Base64 编码后不得超过 10M;为避免 OA 的 10MiB 原文件上限在编码后超限,正式发票应增加不高于 7MiB 的 OCR 输入门禁。
- 本系统一份 PDF 对应一张发票,只识别第一页:`EnablePdf=true`、`PdfPageNumber=1`、`EnableMultiplePage=false`。
- 不请求切图、字段坐标、二维码或印章返回,避免无用数据和额外敏感内容。
- 默认接口频率为 5 次/秒;当前 job 串行处理,远低于该上限。
### 3. 返回结构和业务字段
- 住宿增值税/电子普通发票:从对应 VAT 子类型归一化发票代码(存在时)、号码、日期、不含税金额、税额、价税合计、销售方和购买方。
- 电子发票(铁路电子客票):从 `ElectronicTrainTicketFull` 归一化发票号码、开票日期、价税合计、税额和购买方;该票种可以没有发票代码和销售方,不能继续套用现有 VAT 五字段必填规则。
- 归一化结果需要保留安全的 `documentSubtype`,用它选择必填字段和脱敏处理;不得把乘车人姓名、证件号、完整票面字段或腾讯原始响应写入 `OcrResult.result_json`。
- 电子票据没有发票代码时,不再把发票号码复制到代码字段。文件 SHA-256 查重继续生效;号码级查重需要按已知无代码子类型做显式规则,不能依赖伪造代码绕过现有复合键。
### 4. CAM 最小权限
腾讯官方确认 OCR 支持接口级自定义授权,接口级授权的 `resource` 必须为 `*`。运行身份只需:
```json
{
"version": "2.0",
"statement": [
{
"effect": "allow",
"action": ["ocr:RecognizeGeneralInvoice"],
"resource": ["*"]
}
]
}
```
运行子用户不需要 `QcloudOCRFullAccess`,也不需要开通/关闭服务或控制台管理权限。OCR 服务开通、计费设置和告警由管理员在控制台完成。
### 5. 免费接口选择、计费和错误
- 官方免费额度表明确列出:`通用票据识别(高级版)` 使用同名专用资源包,免费额度为每月 1,000 次,当月有效、每月发放。它不是与其他票据接口共用的模糊额度。
- `增值税发票识别` 也有每月 1,000 次专用免费额度,但只能解决住宿常见 VAT/电子普通发票,不能统一覆盖电子发票(铁路电子客票)。
- 独立的 `火车票识别` 位于官方“仅老客户续费相关接口”区域,且增加第二套 API、权限、归一化和错误处理,不适合作为当前新接入方案。
- 因此最省钱且最简单的方案是只接 `RecognizeGeneralInvoice`:一个接口同时覆盖住宿和铁路电子客票,并消耗通用票据识别(高级版)的每月 1,000 次专用免费额度。按当前 8 人规模通常足够,但免费政策不是永久合同,上线前仍必须在实际账号控制台确认免费包已到账。
- 高级版免费额度耗尽后,如已购买预付费包则继续扣包;如开通后付费则可能继续计费。官方当前后付费起价为 0.15 元/次。
- 为确保零费用,生产应使用中国大陆地域,在 OCR 控制台设置页确认后付费关闭;免费额度耗尽后允许 OCR 不可用并进入可解释失败,不为保持识别可用而自动付费。境外地域按国际站后付费计费,不能抵扣国内账号资源包,因此不在本轮方案内。
- 官方 FAQ 说明,2021 年 12 月 14 日前开通 OCR 的客户在资源包耗尽后可能自动开通后付费;无论账号何时开通,都以实际控制台开关状态为准,不能只凭开通日期推断不会收费。
- 通用票据识别高级版按实际识别出的发票张数计费;增值税发票进入大票计费项,铁路电子客票/全电发票进入小票计费项。
- 每份 PDF 必须只含一张票据,使一次正常识别对应一次额度扣减;混贴或多票文件可能按实际识别张数扣减,应用继续拒绝多票结果。
- 免费/付费资源包耗尽且未开后付费时会不可用;应用需把它分类为额度/计费门禁失败,而不是票据质量失败。腾讯云侧同时设置费用或余额告警作为兜底。
- 高级版接口官方错误码已核对:`ResourceUnavailable.ResourcePackageRunOut` 表示资源包耗尽,`ResourceUnavailable.InArrears` 表示欠费,`ResourcesSoldOut.ChargeStatusException` 表示计费状态异常,`FailedOperation.UnOpenError` 表示服务未开通,`LimitExceeded.TooLargeFileError` 表示文件过大。这些状态对同一输入重试没有收益,应用应一次终止并给出配置/额度类提示。
- 大部分失败不计费,但官方明确有少量失败错误也可能计费。应用必须保留有限重试和可解释终态,不能无限重试。
- 需要区别处理未开通、欠费/资源包耗尽、文件过大、图片解码/模糊和一般 OCR 失败;不得把配置或额度错误伪装成票据质量问题。
## 当前实现差距
1. `server/src/services/ocr/tencent.js` 调用 `VatInvoiceOCR`,没有铁路电子客票结构解析。
2. `server/src/services/ocr/index.js` 对所有 `vat` 强制要求代码、号码、日期、金额、销售方;铁路电子客票和部分数电票会被误判失败。
3. 现有归一化会在代码/号码缺失时互相回填,掩盖无代码电子票据并污染复合查重语义。
4. 现有归一化把腾讯完整响应放进 `raw`,随后持久化到 `OcrResult.result_json`;改用高级版后可能保存乘车人证件信息。
5. OA 发票上传使用通用 10MiB 上限,可能产生“上传成功、腾讯因 Base64 超限失败”的死路。
6. 旧版腾讯 `OcrResult.result_json` 可能仍带 `raw`;新 adapter 不再生成还不够,命中历史缓存时也必须先按安全白名单压缩,避免旧格式绕过新隐私边界。
## 推荐
只使用 `RecognizeGeneralInvoice`,不拆成 `VatInvoiceOCR + TrainTicketOCR` 两个接口。先完成腾讯 adapter、票种归一化、按票种质量门禁、敏感响应最小化、7MiB PDF 门禁和聚焦测试;再由用户在实际腾讯云账号确认高级版每月 1,000 次免费包已到账、中国大陆地域、后付费关闭和费用告警,最后创建最小权限密钥并部署。住宿发票和铁路电子客票各用一份已授权 PDF 做真实验收;在此之前不允许真实票据进入当前 `mock` 环境。
@@ -0,0 +1,32 @@
{
"id": "travel-reimbursement-guide",
"name": "travel-reimbursement-guide",
"title": "差旅报销指南与腾讯OCR正式启用",
"description": "配置并验证生产腾讯OCR,随后固化火车票、住宿费发票和支付截图的提交位置、实报实销及财务复核规则。",
"status": "in_progress",
"dev_type": null,
"scope": "生产腾讯OCR启用、授权PDF样票验证及差旅报销员工指南与支付凭证规则",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"docs/员工快速上手.md",
"docs/报销发票附件规则.md",
"server/src/routes/reimbursements.js",
"server/src/routes/invoicePool.js",
"client/src/pages/ReimbursementDetailPage.tsx"
],
"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,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": {}
}
@@ -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."}
@@ -75,14 +75,20 @@ isolated drill
## 真实职责写入门
- 先读取全量候选并在内存中按目标姓名匹配,只输出两个匹配计数、身份哈希是否不同、缺失字段数和计划动作数量。
- 从管理员通讯录文件在本地内存中筛选两名目标,只把两人子集通过 stdin 送入生产 dry-run/apply;其余四人不进入生产输入、不创建账号,留待后续独立批次。
- dry-run 和 apply 只输出两个匹配计数、身份是否不同、缺失字段数、冲突数和计划动作数量,不持久化筛选文件、完整路径或原始行。
- 只有两个匹配计数均为 1、企微身份不同、均为 active、必填资料完整且手机号/邮箱不冲突时,才进入 apply。
- 员工和企微绑定通过既有 HR master sync 服务创建或更新;随后通过既有角色写入语义分别设置 `employee + finance` 和 `employee + ceo`。
- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、审计存在和付款业务零写入。
- `finance` 的批准范围明确包含 `payroll.manage`、`employee.bank.read`、财务付款和付款批次管理,不包含 `asset.transfer`;不以 `finance_mgr` 解决 CEO 本人报销兜底。
- 角色变更递增 `token_version`、撤销活动会话并写 `SYSTEM_USER_ROLES_SET` 审计;最终只核对数量、角色集合、不同 employee id、角色待办计数变化、审计存在和付款业务零写入。
- maker-checker 只验证付款批次创建人不能复核同一批次,不宣称费用审批与财务处理完全分离;生产不创建测试付款批次,使用合成路由测试和生产只读身份/权限证据。
- 回滚是通过相同角色接口恢复两人的写入前角色集合,并再次撤销会话、保留审计;不得通过整库恢复回退角色。
- 任一门禁失败即停止,不进行部分角色写入;如员工同步已经成功而角色阶段失败,保留同步审计并立即报告,不用删除真实员工记录进行破坏性回滚。
## 企微本人敏感信息授权边界
- 本轮生产身份建档不走本人授权,而走管理员通讯录两人子集。以下 OAuth 合同继续保留为已实现、默认关闭的未来补资料能力,本轮验收标记为不适用,不要求成员点击授权链接。
- `/user/get` 和人事助手批量读取继续只负责 userid、姓名、部门等可读字段;不得把批量缺字段误判为企微完全不支持手机号和邮箱。
- 2022-06-20 后新建自建应用的敏感字段走独立 OAuth 数据流:管理员在应用详情选择字段,成员以 `snsapi_privateinfo` 手工确认,`getuserinfo` 返回短时 `user_ticket`,服务端立即调用 `/cgi-bin/auth/getuserdetail`。
- `user_ticket`、授权 code 和完整敏感响应不得写日志、任务证据或审计 extra;只保存业务所需的规范化手机号/邮箱及字段存在性审计。
@@ -108,6 +114,16 @@ GET /auth/wecom/login?mode=onboard
- 已有绑定员工只补齐经授权的规范化资料且不改角色;新员工在单事务内创建 employee、`employee` 角色、企微绑定和脱敏审计。任何一步失败均不留下部分账号。
- 自助开通不替代管理员职责分配;`finance`、`ceo` 仍沿用角色接口的 token version、会话撤销和审计合同。
## 企微通讯录导出与手机号可空
- 管理员导出的 XLSX 只作为一次性受控输入,不进入 Git、Trellis、报告或生产文件系统;CLI 支持从 stdin 读取,默认仅 dry-run。
- 入口识别企微官方说明行和中文表头,将姓名、账号、职务、部门、手机、邮箱/企业邮箱、激活/禁用状态归一化为既有 HR master profile,再复用部门解析、员工匹配、企微绑定、审计和 `employee` 初始角色语义。
- `Employee.phone` 改为 nullable unique:缺失值统一保存为 `NULL`,非空值继续唯一。企业邮箱仍必填且唯一,企微账号仍是绑定主键;不生成占位手机号。
- 来源未提供手机号时,不得把既有 OA 手机号清空;来源提供手机号时才进入变更和唯一性检查。本人授权同样允许手机号缺失,但邮箱或企业邮箱仍必须有效。
- apply 需要命令参数和独立环境变量双门,且真实运行另行批准;dry-run 与 apply 的报告都只含计数、动作、冲突类别和脱敏摘要。
- 本轮在内存中筛选两名目标后再送入 stdin;筛选结果不落盘,目标外四人不进入 dry-run/apply,也不因出现在原始导出文件中而获得 OA 活跃账号。
- 生产迁移回滚前必须确认不存在 `phone IS NULL`;否则停止回滚,不用假数据填充。
## 部署目录统一设计约束
- 现场是混合部署,不能把“统一目录”处理成普通 rsync:必须以当前活动 PostgreSQL 数据和附件挂载为唯一数据事实源。
@@ -131,4 +147,4 @@ GET /auth/wecom/login?mode=onboard
2. 本地先修复 Compose 可配置回环端口及部署脚本动态端口探测,通过全部门禁并提交、发布批准提交。
3. 停机后把活动 PGDATA 和附件复制到新的显式绝对目录,复制前目标必须为空;保留旧目录原位且不再挂载写入,直到观察期结束。复制仅在旧 PostgreSQL 完全停止后执行,并保留所有权、权限、链接和扩展属性。
4. 用新 Compose 重建 db/init/server,应用已提交 migrations;旧容器和旧目录在验收前保留,不清理。
5. 公网构建标识、数据计数、角色、企微入口和关键只读检查通过后更新运维 timer;失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。
5. 公网构建标识、数据计数和关键部署只读检查通过后更新运维 timer;角色与企微入口仍按各自真实写入门单独验收,不阻塞部署运维基线收口。部署检查失败则停止新容器并恢复旧目录/容器和原 Caddy 目标。
@@ -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."}
@@ -13,22 +13,29 @@
- [x] 经明确授权生成并复制过 DB+附件+manifest 批次;后续发现附件来自非活动目录,已将该批次降级为无效恢复证据并保留审计。
- [x] 经后续明确授权,从活动 DB 与活动 server 附件挂载源重新生成正确批次并完成异机复制与目标端复验。
- [x] 经后续明确授权,使用正确异机批次完成真实备份格式的 DB+附件隔离影子恢复,并逐项复验迁移数、关键表计数和附件数量。
- [ ] 经后续明确授权完成真实职责分配与部署/迁移,并在部署环境复验应用 `/ready`、构建标识和数据连续性。
- [x] 经后续明确授权完成真实职责分配与部署/迁移,并在部署环境复验应用 `/ready`、构建标识和数据连续性。
- [x] 修复企微 HR 双 Secret 分工:通讯录 token 只列 userid,自建应用 token 读取详情、部门和 HR 信息。
- [x] 补充单元测试并运行后端语法、聚焦测试、Prisma 与 Trellis 门禁。
- [x] 在真实环境执行两名目标成员的脱敏 dry-run;两人均唯一、不同身份且 active,但批量链路均缺手机和邮箱,已按门禁停止真实写入。
- [ ] 复用既有 HR 同步与角色语义写入 `employee + finance`、`employee + ceo`,复核会话失效、审计和 maker-checker,不部署、不迁移。
- [x] 复用既有 HR 同步与角色语义写入 `employee + finance`、`employee + ceo`,复核会话失效、审计和 maker-checker;角色阶段未附带部署或迁移。
- [x] 增加默认关闭的一次性企微敏感资料授权入口,保持普通登录静默;实现 `user_ticket -> getuserdetail`、active/字段/唯一性门禁和仅 `employee` 自助开通。
- [x] 补齐 OAuth 路由、服务、配置契约和单元测试,证明票据/原始敏感响应不进入日志、Cookie、数据库或审计。
- [ ] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后才执行获批切换。
- [x] 修复部署脚本的现场端口解析,确定唯一部署目录,并在活动 DB/附件挂载、环境摘要和旧镜像回滚点全部确认后执行获批切换。
- [x] 完成本地 Compose/备份加固:移除固定容器名和 DB 宿主端口,参数化数据目录,deploy/timer 显式 project,备份来源缺失硬失败,远端构建标识改由容器内 Node 核验。
- [x] 增加隐式附件源、隐式 DB 容器、DB 端口与固定容器名回退的负向契约演练;未部署或启用新 Compose。
- [x] 收紧正式 deploy 数据路径:显式绝对路径、`existing|initialize` 模式、既有 PGDATA 标记和禁止 Docker 静默建空目录。
- [x] 让成对 manifest 尊重显式 `BUILD_SHA` 并支持活动镜像 digest;补 Node/Python 等价与非法来源标识负向测试。
- [x] 把旧 Compose project 实测、镜像 digest、58 项迁移与关键表计数、影子逐表/附件计数验收、停机后复制并保留旧目录写入 runbook/checklist/spec。
- [ ] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。
- [ ] 用户在企微应用详情选择手机、邮箱、企业邮箱敏感字段;部署开关开启后,两名目标分别通过一次性链接本人确认授权。
- [ ] 两人自助开通成功后写入获批 `finance` / `ceo` 职责,复核不同身份、maker-checker、会话撤销、审计和付款零写入。
- [x] 部署后验证唯一 Compose 来源、构建标识、迁移状态、Caddy 公网 `/ready` 和前端 marker;不因服务可访问而忽略来源漂移。
- [x] 本轮不适用/延后:不启用企微敏感资料 OAuth 开关,不要求两名目标点击一次性授权;入口保持默认关闭,未来仅用于本人补资料。
- [x] 从管理员通讯录在内存中筛选两名目标,先执行生产脱敏 dry-run;其余四人不进入输入、不创建账号,另轮处理。
- [x] dry-run 零冲突后另行批准 apply,确认两人账号/企微绑定唯一且初始角色严格为 `employee`,不持久化通讯录文件。
- [x] 记录两人原角色、活跃会话、角色待办和审计基线,再分别写入 `employee + finance` / `employee + ceo`;明确 `finance` 含薪资/银行资料权限且不含 `asset.transfer`,不授予 `finance_mgr`。
- [x] 复核角色接口递增 token version、撤销会话和 `SYSTEM_USER_ROLES_SET` 审计;以合成测试证明付款批次 maker-checker,不在生产创建测试付款数据,也不把结论扩大为费用审批完全分离。
- [x] 回滚合同固定为只通过相同角色接口恢复两人原角色集合并复核会话/审计,不恢复整库;本次写入和验证均成功,未触发回滚。CEO 既有角色待办可见性变化及本人无主管报销继续阻断已记录。
- [x] 将 `Employee.phone` 改为 nullable unique,并同步员工创建/批量导入、企微 HR master、本人授权、登录查询和前端空值合同;不执行生产 migration。
- [x] 增加企微通讯录 XLSX/stdin 默认 dry-run 入口,复用 HR master 写入语义,apply 使用独立双门且本阶段不执行。
- [x] 用完全合成的通讯录覆盖“5 个手机号 + 1 个缺失”、重复账号/手机号/邮箱、说明行/中文表头、既有手机号不被空来源清除和报告隐私门禁。
## 推荐文件边界
@@ -52,6 +59,7 @@
- `cd server && npm run prisma:validate`
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js`
- `cd server && npm test -- --run tests/unit/routes/wecomAuth.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js`
- `cd server && npm test -- --run tests/unit/routes/users.import.test.js tests/unit/wecomHrMaster.service.test.js tests/unit/wecomOAuthOnboarding.service.test.js tests/unit/scripts/import-wecom-directory-export.test.js`
- `bash -n deploy/trialrun/deploy.sh`
- `bash -n deploy/trialrun/install-warch3-ops-timer.sh`
@@ -71,6 +79,27 @@
- 已从活动 DB 容器与活动 server 附件挂载生成正确批次并复制到 ARM 备份机;DB catalog、manifest、源/目标产物 SHA-256、附件 tar 与 0 个普通文件计数均通过,且未伪造旧实例 build SHA。ARM 只承担异机备份与恢复演练职责,不运行 OA 服务。
- 已在 ARM 备份机用不发布端口的一次性 PostgreSQL 16 容器恢复正确批次:58 项成功迁移、12 张关键表计数和 0 个附件普通文件均与冻结基线一致,包含镜像拉取的实测 RTO 为 33 秒。演练后容器、临时附件目录和本轮新增镜像均已删除,未运行 OA server;应用 `/ready` 留待生产部署环境验证。
## 手机号可空与通讯录导入本地证据(2026-07-27)
- 后端全量 Vitest:163 个测试文件、1016 个测试通过;补充双 apply 门断言后,通讯录导入聚焦 8 项再次通过。新增覆盖普通用户创建/显式清空、CSV 空手机号、HR 空来源保留既有值、OAuth 无手机号建档、通讯录说明行/中文表头、企业邮箱门禁、非法手机号、重复字段、apply 双门及脱敏状态。
- `prisma validate`、四个后端改动文件语法、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。
- 管理员提供的真实通讯录仅执行解析级只读检查:6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库、未执行 apply、未分配角色、未发送企微消息,文件保持 Git 排除。
- 最终只读复核确认 migration 仅删除手机号 `NOT NULL`、唯一索引保留;导入及本人开通只创建 `employee`,不自动授予 `finance`、`ceo`、`admin` 或 `system_admin`;空来源更新省略 phone 字段,真实文件名、完整路径和个人字段未进入差异。
- 获批生产脱敏 dry-run 只接收内存筛选的两人子集,结果为输入 2、active 2、不完整 0、计划创建 2、更新 0、冲突/跳过/失败均为 0;两名目标均有手机号和企业邮箱。dry-run 未写库、未创建账号、未分配角色,目标外四人未进入输入。
- 生产 Node 对 Promise 版 `readFile(0)` 返回 `ERR_INVALID_ARG_TYPE`,正式 CLI 在解析前安全失败;本地改为 `fs.readFileSync(0)` 并增加 fd 读取回归。生产证据先由同一解析、同步和报告逻辑的 fd 兼容只读入口取得;修复随 `4886f9b` 部署后,正式 CLI 再次得到相同结果,本阶段未执行 apply。
## 生产部署与 timer 收口证据(2026-07-27)
- 已在获批停机窗口把生产切换到唯一 Compose project;活动 DB 与附件从停机冻结副本进入新的独立目录,旧容器保持停止,旧数据目录原位保留作回滚点。
- 生产已应用 61 项 migration;12 张关键表计数与冻结基线一致,活动附件普通文件数仍为 0。server 仅回环发布,DB 不发布宿主端口。
- 容器内与公网 `/ready`、前端 marker 均返回批准提交的同一 12 位构建标识;runtime smoke、external-deps smoke 和公网 bundle/marker 检查通过。
- 运维 timer 安装器已要求显式绑定 Compose project、活动 PGDATA、活动附件目录、完整后端 SHA 和 12 位前端 SHA。生产 timer 已重新安装并保持 enabled/active,daily service 未被手工触发;unit 环境与活动挂载/构建标识一致,同环境 Compose config 与 server exec 解析通过。
- timer 首次收口时发现从 deploy 复制的旧路径正则会误拒绝所有绝对路径;安装在写 unit 前安全停止。现已与 deploy 的根目录、尾随斜杠、双斜杠及点路径段门禁保持一致,并由环境契约阻止旧正则回退。
- 本轮没有启动真实告警任务,没有修改员工、角色、付款、企微后台或企微开通开关;职责分离与两人本人授权继续保持未完成。
- `4886f9b` 已通过正式部署入口发布:来源 published/remoteVerified,62 项 migration 无待应用项,容器与公网 `/ready`、external deps smoke、runtime smoke、公网前端构建 marker 和资源扫描通过;生产环境文件未覆盖。部署后的通讯录正式 CLI dry-run 为输入 2、计划创建 2、冲突/跳过/失败 0,未写员工、绑定或角色。
- 写入前自动备份、ARM dead-man 和管理员 actor 门禁通过。两人 apply 创建 2、成功 2;写后为 2 名 active、2 个不同企微绑定、初始角色均仅 `employee`。角色接口写入后 token version 合计递增 2、活动会话 0、`SYSTEM_USER_ROLES_SET` 审计新增 2、CEO 待办 0→0、付款批次数量变化 0。
- 最终生产只读权限核对通过;本地 `permissions` 与 `paymentBatches.reimbursement` 聚焦回归共 40 项通过,maker-checker 只由合成测试验证,未产生生产测试付款。目标外四人未进入本轮输入;本次未触发角色回滚。
## 回滚点
- 成对入口失败:保留四个底层脚本,不写完整 manifest。
@@ -22,6 +22,8 @@
- 经用户逐项授权后,修正企微通讯录与人事助手的双 Secret 读取分工,并用脱敏 dry-run 唯一确认两名既有企微成员。
- 唯一匹配且必填资料、冲突检查通过后,将两名成员同步为 OA 员工,并分别配置 `employee + finance` 与 `employee + ceo`,形成不同自然人的付款制单/复核职责。
- 增加独立的一次性企微敏感资料授权入口:普通登录继续使用 `snsapi_base`,首次开通使用 `snsapi_privateinfo`;未绑定的在职可见成员授权成功后只自动获得 `employee` 角色。
- 支持管理员提供的企微通讯录 XLSX 通过 stdin 做脱敏 dry-run,并允许个别成员暂缺手机号;企业邮箱、企微账号、姓名和部门仍作为建档门禁。
- 本轮通讯录导入只筛选两名已具备职责验收计划的目标成员;其余四名成员不进入 dry-run apply 输入、不创建活跃账号,明确留待后续独立批次。
- 修复部署脚本与现场端口、目录、容器和构建标识契约,统一真实运行来源;切换前保留活动数据、环境文件和旧容器回滚点。
## Requirements
@@ -37,10 +39,16 @@
- 将真实内部实例核查列为后续同任务外部门禁:运行 commit/访问方式/TLS、真实职责分配、异机副本和恢复后 `/ready` 均需另行批准和脱敏留证。
- 企微通讯录 Secret 只用于 `/user/list_id`;自建应用 Secret 用于 `/user/get`、`/department/list` 和获授权的人事助手 `/hr/get_staff_info`,不得回退到已废弃的人事助手 Secret。
- 真实同步前只输出匹配数量、身份是否不同、缺失字段数、冲突数和计划创建/更新数,不输出姓名、userid、手机号、邮箱、部门原文或原始 payload。
- `Employee.phone` 允许为 `NULL`,但不得写空字符串或假手机号;非空手机号继续保持唯一。企微账号和企业邮箱必须非空且唯一,缺手机号成员使用企微或邮箱登录,后续可由本人授权或管理员补齐。
- 真实写入必须复用现有员工同步、角色、会话撤销和审计语义;财务处理人与 CEO 复核人必须是两个不同企微身份,且不得把 `admin` 当作普通业务复核角色。
- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并实际同意手机号及邮箱或企业邮箱授权。
- 目标角色固定为 `employee + finance` 与 `employee + ceo`,不授予 `finance_mgr`。接受 `finance` 同时具备 `payroll.manage` 和 `employee.bank.read`,意味着财务处理人可访问薪资和员工银行账号;`finance`、`finance_mgr` 均不得拥有 `asset.transfer`。
- 本轮 maker-checker 验收只针对付款批次复核:创建人复核同一批次必须被 `maker_checker_conflict` 拒绝;不得把结论表述为费用审批、财务付款或全部财务操作已完全按自然人分离。
- 敏感资料授权必须显式受 `WECOM_OAUTH_ONBOARDING_ENABLED` 控制,默认关闭;成员必须在应用可见范围、企微状态 active,并至少实际同意邮箱或企业邮箱授权;手机号可暂缺,但返回时必须规范化并参与唯一性检查。
- `user_ticket` 只在服务端内存中用于一次 `getuserdetail` 调用,不写日志、Cookie、数据库、审计或错误详情;授权 code 和原始敏感响应同样不得持久化。
- 自助开通按企微 userid、手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。
- 自助开通按企微 userid、非空手机号和邮箱做唯一性检查;新账号只创建 `employee` 角色,随机不可知密码并要求改密,不自动授予财务、CEO、管理员或审批角色。
- 本轮真实建档改用管理员导出的通讯录两人子集:先以 stdin dry-run/apply 创建或更新 employee 与企微绑定,且新账号严格只有 `employee`;再由管理员单独写入附加角色。一次性 `snsapi_privateinfo/getuserdetail` 入口保持默认关闭,仅作为未来本人补资料能力,不作为本轮生产验收门禁。
- 角色写入前后记录目标原角色集合、不同 employee 身份、对应角色待办计数、活跃会话数量和审计计数;回滚只把两人恢复为写入前角色集合,复用 token version、会话撤销和审计,不恢复整库。
- CEO 角色会让既有 `assignee_role=ceo` 待办立即可见,并影响新提交生成的角色节点,但不重写既有审批快照,也不恢复过去失败的提交。CEO 本人无主管且没有 active 非本人 `finance_mgr` 时,报销保持阻断,本轮不通过扩权绕过。
- 普通 `snsapi_base` 登录、已绑定账号登录和现有绑定流程保持兼容,不因资料授权功能改成每次手工确认。
- 生产统一以仓库既定部署目录和 Compose 契约为目标,但活动 PostgreSQL 与附件数据是唯一数据事实源;停机切换不得复制运行中的 PostgreSQL 文件、启动第二套空库或覆盖环境变量。
- Compose 项目名、容器身份与 DB/附件目录必须在同一次获批停机窗口切换;DB 不发布宿主端口,备份不得猜测容器名或附件目录。
@@ -56,24 +64,26 @@
- [x] 隔离合成数据演练证明 DB 与附件可往返恢复,并记录实际耗时作为本地 RTO 参考。
- [x] runbook/checklist 明确影子演练命令、生产停止条件、DB + 附件配对要求和异机副本要求。
- [x] 相关 shell 语法、QA 脚本、Trellis 治理、敏感扫描和 `git diff --check` 通过。
- [ ] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。
- [x] 在获得单独授权后,确认真实运行 commit、访问边界、职责分离、异机备份及恢复后 `/ready`;未授权前保持未勾选且不宣称正式可用。
- [x] 双 Secret 调用测试证明成员 ID 由通讯录 token 读取,成员详情、部门和 HR 资料由自建应用 token 读取。
- [ ] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。
- [ ] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。
- [x] 真实脱敏 dry-run 证明两个目标各唯一匹配、身份不同、必填资料和唯一约束无阻塞。
- [x] 获批写入后,两名活跃员工分别具有 `employee + finance` 与 `employee + ceo`,企微绑定不同,角色审计存在且未产生付款业务写入。
- [x] 普通企微登录仍生成 `snsapi_base`,一次性开通入口生成 `snsapi_privateinfo`;缺少开关、`user_ticket`、成员授权字段、active 状态或唯一性时均拒绝创建(本地 synthetic 验证)。
- [ ] 一次性授权通过 `getuserdetail` 获取成员同意的手机和邮箱,自动创建或补齐 OA 员工与企微绑定;新建账号角色严格等于 `employee`,日志和审计不含票据或完整敏感响应。
- [ ] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。
- [x] 本轮不适用:生产不启用 `snsapi_privateinfo/getuserdetail` 本人授权,OAuth 入口保持默认关闭并延后为补资料能力;真实建档改用企微通讯录两人子集,仍要求新账号角色严格等于 `employee`,且不持久化通讯录文件或原始行。
- [x] 部署目录、Compose、环境文件、运维 timer 和活动容器来源统一;活动 DB/附件连续,远端 migrations 与批准提交一致,公网 `/ready` 和前端 marker 返回同一短构建标识。
- [x] 本地 Compose、部署、timer 和备份合同完成加固并覆盖负向回退;未把本地通过误报为生产切换完成。
- [x] 部署入口拒绝隐式/相对数据目录及缺少数据模式,成对 manifest 可记录显式 build SHA 或活动镜像 digest,并有 Node/Python 等价测试。
- [ ] 两名目标成员分别完成一次本人授权后,账号唯一、企微绑定不同,再由管理员写入 `employee + finance` 与 `employee + ceo`;maker-checker、会话撤销和角色审计通过。
- [x] 两名目标成员经企微通讯录两人子集 dry-run/apply 后账号唯一、企微绑定不同且初始角色仅为 `employee`,再由管理员写入 `employee + finance` 与 `employee + ceo`;其余四人保持未导入,付款批次 maker-checker、会话撤销和角色审计通过。
- [x] 本地合成企微导出证明 5 人有手机号、1 人手机号为空时 dry-run 可通过;空值保存为 `NULL`,非空手机号/邮箱/企微账号重复均被拒绝,报告不含个人字段原文。
## Out of Scope
- 不读取真实附件正文,不执行生产恢复、数据库 reset、迁移历史改写或付款/报销业务写入。
- 不修改 Prisma schema、公开业务 API、权限模型、审批链或付款状态机;仅修复既有 HR 同步服务的凭证分工,并通过既有角色模型写入获批职责。
- 除已批准的 `Employee.phone` nullable 兼容外,不扩大修改其他 Prisma schema、权限模型、审批链或付款状态机;公开用户 API 仅同步手机号可空合同,并通过既有角色模型写入获批职责。
- 不引入 MinIO、云备份 SDK、全量灾备平台、自动跨地域复制或 24×7 值守。
- 不在仓库提交备份文件、恢复数据、环境变量、凭证、真实账号或完整本地路径。
- 不发送企微消息、不创建企微审批模板、不把手机号/邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。
- 不发送企微消息、不创建企微审批模板、不把邮箱改为可空、不写占位假数据,也不将敏感授权变成普通登录的强制步骤。
- 不导入通讯录中的其余四名成员,不授予 `finance_mgr`,不修改审批规则来放行 CEO 本人无主管报销。
- 不在本任务完成企微调休、导航、报销或模块退役;它们属于后续批次。
## Test Plan
@@ -97,13 +107,16 @@
先改进可恢复性和防误操作,再接触真实环境。默认工作流只支持影子恢复;真实恢复不是普通脚本路径,而是需要停机、负责人确认、成对备份、回滚点和独立授权的事故操作。
企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料,不修改全局员工字段可空性;自助开通只授予 `employee`,业务角色继续由管理员审计写入。
企微敏感字段采用“日常静默登录 + 一次性本人授权开通”的双入口。批量同步不猜测敏感资料;手机号按已批准范围改为全局 nullable,空值统一保存为 `NULL`。自助开通只授予 `employee`,业务角色继续由管理员审计写入。
## Local Evidence
- 已使用一次性 PostgreSQL 16 容器和合成附件完成多轮往返演练,最近一次本地参考 RTO 为 6 秒。
- 演练覆盖 manifest/校验和、错误确认、正式风格库名、损坏 DB、损坏 tar、非空附件目录、默认试运行附件目录和危险 tar 路径拒绝。
- 该证据不包含真实业务数据、真实运行实例、异机副本或部署后 `/ready`,因此最后一项验收保持未完成。
- 2026-07-27 完成手机号 nullable 本地切片:Prisma 仅新增 `phone DROP NOT NULL` migration,用户创建/更新/批量导入、HR master、本人授权和前端空值合同已同步;本轮没有执行 migration 或写入真实员工。
- 合成通讯录覆盖说明行/中文表头、5 个手机号加 1 个缺失、非法手机号、缺企业邮箱、重复账号/非空手机号/企业邮箱、双重 apply 门和脱敏报告。管理员提供的真实 XLSX 仅在内存中只读解析为 6 名 active、5 个手机号、1 个缺失、6 个企业邮箱、0 个不完整;未连接数据库,文件保持 Git 排除。
- 最终本地门禁:后端全量 163 个测试文件、1016 个测试通过;补充双 apply 门测试后,通讯录导入聚焦 8 项再次通过。Prisma validate、前端 typecheck/build、Trellis task/governance、敏感扫描、流程合同、文档注册审计和 `git diff --check` 通过。文档审计保留仓库既有 22 个未注册文件 warning,无新增 error。
## Live Read-Only Evidence
@@ -118,3 +131,7 @@
- 企微结构化探测证明 6 名成员的批量详情接口均成功并返回部门,但手机号、邮箱和企业邮箱可读数量均为 0;HR 接口均成功但没有 `staff_info`。官方文档确认这不代表字段不可获取:2022-06-20 后新建自建应用应由管理员在应用详情选择敏感字段,再由成员通过 `snsapi_privateinfo` 手工授权,并用 `user_ticket` 调 `/cgi-bin/auth/getuserdetail` 获取本人手机号、邮箱或企业邮箱。
- 当前 OA OAuth 只使用 `snsapi_base`,且没有调用 `/cgi-bin/auth/getuserdetail`,因此现有登录链路不能采集敏感字段。两名目标各唯一匹配且身份不同,但批量 dry-run 均缺 `phone`、`email`,真实写入已按门禁停止。
- 本轮已在本地实现默认关闭的一次性 `onboard` 入口;普通登录继续静默。全量后端门禁与安全补修后的企微聚焦回归结果记录在 `implement.md`。该证据不代表功能已部署,也不代表企微后台敏感字段已由管理员选择。
- 2026-07-27 经明确授权,仅把管理员通讯录的两人内存子集通过 stdin 送入活动 server 做生产 dry-run:输入 2、active 2、手机号存在 2、企业邮箱存在 2、不完整 0,计划创建 2、更新 0、冲突 0、跳过 0、失败 0;未执行 apply、未写数据库、未持久化筛选文件,其余四人未进入输入。
- 首次正式 CLI dry-run 暴露生产 Node 对 `fs.promises.readFile(0)` 不兼容并在解析前停止;随后用同一解析器、同一 HR master 服务和同一脱敏报告构造器,仅将 stdin 读取替换为 fd 兼容方式完成上述 dry-run。本地修复与回归已随 `4886f9b` 部署,部署后正式 CLI 复验得到相同脱敏结果;本阶段仍未授权 apply。
- 2026-07-27 经再次明确授权执行两人子集 apply:创建 2、成功 2、冲突/跳过/失败 0;写后确认 2 名 active 员工、2 个不同企微绑定且初始角色均严格为 `employee`。随后通过现有角色接口写入 `employee + finance` 与 `employee + ceo`,token version 合计递增 2、活动会话保持 0、角色审计新增 2、CEO 待办 0→0、付款批次数量变化 0。
- 最终生产只读核对确认两人角色与权限矩阵一致,`finance` 包含已接受的薪资/银行资料权限且不含 `asset.transfer`,`ceo` 包含付款复核且不含 `asset.transfer`;本地合成权限和付款批次 maker-checker 回归 40 项通过,未在生产创建测试付款数据。目标外四人未进入本轮 stdin,未由本轮创建账号。
@@ -111,3 +111,12 @@
- 已使用正确异机批次在不发布端口的一次性 PostgreSQL 16 容器中完成真实备份格式影子恢复;58 项成功迁移、12 张关键表计数与冻结基线逐项一致,附件归档安全检查和解包后的 0 个普通文件也一致,包含镜像拉取的实测 RTO 为 33 秒。
- 恢复演练后,一次性数据库容器、临时附件目录和本轮新增 PostgreSQL 镜像均已删除;ARM 继续只承担备份与恢复演练职责,没有运行 OA server 或开放 OA 端口。
- 当前证据证明 DB+附件异机恢复点可用,但不替代部署环境的应用 `/ready`、构建标识、Caddy 和前端 marker 验收。生产服务未停止,数据库、附件、容器、Caddy、timer、角色和企微均未修改。
## 2026-07-27 获批生产切换与运维收口
- 已完成获批生产切换:新 Compose project 成为唯一活动来源,旧项目容器保持停止;旧数据目录和旧容器均保留,正式活动数据使用停机后复制的新目录。
- 生产共 61 项 migration 成功,12 张关键表计数与冻结基线一致,活动附件普通文件数为 0;server 只绑定回环地址,DB 未发布宿主端口。
- 容器内与公网 `/ready`、前端 marker 均观察到批准提交的同一 12 位构建标识;runtime、外部依赖和公网资源标识检查通过。
- 运维 timer 已重新安装并显式绑定当前 Compose project、活动 DB/附件目录和前后端构建标识;timer 为 enabled/active,daily service 保持 inactive,未手工触发真实告警。使用同一组脱敏环境事实执行 Compose config 与 server exec 均通过。
- timer 安装前的路径与构建门禁先在本地通过;一次旧正则误拒绝绝对路径后,安装在 unit 写入前停止,随后修复并增加防回退契约。全程未输出或写入 Secret、完整宿主路径和环境原文。
- 部署与恢复基线现已完成;真实职责分配、两名成员本人敏感资料授权和企微开通仍是独立未完成门禁,因此整体仍不得宣称付款 maker-checker 或企微自助开通已可用。
@@ -3,7 +3,7 @@
"name": "internal-ops-recovery-baseline",
"title": "内部OA运行与恢复底线",
"description": "为5至10人内部OA建立低维护、可验证、防误操作的数据库与附件成对备份恢复基线,并明确真实环境授权门。",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": "backup-restore-safety-local-drill-operational-baseline",
"package": null,
@@ -11,7 +11,7 @@
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-26",
"completedAt": null,
"completedAt": "2026-07-27",
"branch": null,
"base_branch": "main",
"worktree_path": null,
@@ -34,4 +34,4 @@
],
"notes": "本地安全基线已普通快进发布,来源门禁复验 PASS;获批真实成对异机备份已完成且两端校验一致。整体仍为 NO_GO:运行实例缺少构建标识、迁移落后、无财务/复核职责分离,角色写入、部署/迁移和真实格式影子恢复仍需另行授权。",
"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,91 @@
# 应用健康监控设计
## 1. Architecture and Boundaries
首版由三个独立部分组成:
1. 仓库内的 Python 探测器:从 ARM 发起公网 HTTPS `/ready` 请求,验证传输、状态码和最小 JSON 合同,维护连续失败计数,并生成只含状态/类别/计数的 0600 摘要文件。
2. 现有 `scripts/send-wecom-group-alert.py`:扩展应用健康允许状态,继续负责 webhook 文件校验、脱敏消息、同类故障降噪和恢复通知。
3. 独立 ARM systemd 安装器:复制探测器和本次已通过备份回归的共用告警 helper 修订,引用既有 root-owned 0600 webhook,写入 oneshot service/timer;`install` 默认只安装 disabled units,`enable` 才启用,`uninstall` 只删除应用健康组件并保留共用 helper 与状态证据。
不修改 Fastify `/ready`,不修改 Compose 拓扑,不引入 Prometheus/Alertmanager,不把应用监控并入备份 receiver 的生命周期。
## 2. Probe Contract
### Input
- 固定要求 `https` URL,拒绝 userinfo、fragment、非预期 query;默认目标为项目公开 OA 的 `/ready`。
- 连接与读取均有短超时,单次 timer 不得长期占用。
- 状态目录、摘要、探测状态和告警状态均由 root 持有,目录 0700、文件 0600,拒绝符号链接和宽权限输入。
### Success
- TLS 证书链和主机名校验通过。
- HTTP 2xx。
- 响应是受大小限制的 JSON,且 `data.ok === true`。
- 不要求固定 `buildSha`,避免正常发布触发误报;不持久化完整响应。
### Failure categories
只输出稳定 token,不输出异常原文:
- `dns_or_connect_failed`
- `tls_validation_failed`
- `request_timeout`
- `http_unavailable`
- `response_too_large`
- `response_contract_invalid`
- `local_state_invalid`
- `alert_evaluation_failed`
HTTP 503 不解析并转发数据库错误详情,只归类为 `http_unavailable`。日志和企微消息不得包含地址解析结果、IP、请求/响应 payload、凭证或绝对路径。
## 3. Failure Confirmation and Alert State
探测器维护独立 probe state:`schemaVersion`、`consecutiveFailures`、`lastCategory`。告警 helper 继续维护自己的 alert state。
状态转换:
```text
healthy ──首次失败──> pending_failure(记录,非零退出,不发消息)
pending_failure ──再次失败──> alerting(调用 helper)
alerting ──相同失败──> helper 降噪
alerting ──类别变化──> helper 发送新类别告警
pending_failure ──成功──> healthy(不发送恢复消息)
alerting ──成功──> healthy(helper 发送一次恢复通知)
```
连续失败按“任意失败”累计,第二次使用当前类别告警;这样不会因 DNS、连接、HTTP 类别在同一事故中抖动而无限推迟。达到阈值后每次都调用 helper,由 helper 根据状态和类别决定是否发送。状态采用同目录临时文件 + 原子替换。
若真实消息发送失败,helper 不更新 alert state,下一次 timer 会重试。探测 service 保持非零退出,便于 `systemctl status`/journal 只读检查。
## 4. Systemd Design
- service:`Type=oneshot`、`UMask=0077`、依赖 `network-online.target`,以 root 运行以读取现有 root-owned webhook;不加载 OA `.env`。
- timer:每 5 分钟执行,`Persistent=true`,开机后补一次错过的执行;不启用随机延迟,以便最长发现时间可预测。
- 安装器校验源文件、目标 URL、状态目录、webhook/helper 权限和依赖;`install` 与 `enable` 分离,避免未经真实告警批准就自动发送。
- timer 位于 ARM,因此可发现 OA 主机整体不可达;ARM 自身宕机仍会导致探测失声,首版通过 runbook 中的定期 `systemctl list-timers` 检查记录该剩余风险,不新增第三方 dead-man。
## 5. Compatibility and Reuse
- `send-wecom-group-alert.py` 仅新增 `FAIL_APP_HEALTH` 与 `PASS_APP_HEALTH_READY` 允许状态;现有 `FAIL_*`/健康恢复逻辑保持不变。
- 现有自动备份契约测试必须继续通过,证明 allowlist 扩展未改变备份消息行为。
- 应用监控使用独立 alert state 文件,不与自动备份或 WARCH3 的降噪状态互相覆盖。
## 6. Rollout and Rollback
实现阶段只产出脚本、测试和现有运维文档更新。远端 rollout 是后续独立批准动作:
1. 在非真实发送测试模式运行健康/失败/恢复状态机。
2. ARM 执行安装但不启用,检查 unit 内容、文件权限和手动健康探测。
3. 另获批准后启用 timer,并进行一次受控失败演练,确认只出现一条告警和一条恢复通知。
4. 只读核对 timer 最近执行与下一次计划时间。
回滚时 disable/stop 并卸载应用健康 units/探测器;不触碰备份 receiver、归档、共用 webhook 和 helper。若 helper 已被其他能力共用,不在应用监控卸载中删除。
## 7. Deferred Items
- 证书到期前 N 天预警。
- 登录或报销等业务合成事务。
- 5xx 比率、延迟分位数和趋势图。
- ARM timer 自身的第三方 dead-man。
@@ -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,47 @@
# 应用健康监控实施清单
## 1. Implementation
- [x] 新增独立 Python 公网 `/ready` 探测器,完成严格 URL、TLS、HTTP、响应大小和 JSON 合同校验。
- [x] 实现 0700/0600 本地状态合同、连续失败计数、原子写入和稳定脱敏类别。
- [x] 扩展 `scripts/send-wecom-group-alert.py` 的应用健康状态 allowlist,保持既有降噪与恢复语义。
- [x] 新增独立 ARM systemd 安装器,包含 `install`、`enable`、`uninstall`、输入/权限校验和 disabled-by-default 行为。
- [x] 新增应用健康契约测试,使用本地 HTTPS/HTTP stub 或受控 mock 覆盖状态机、异常输入和隐私边界,不调用真实 webhook。
- [x] 把新增测试接入最小验证入口;复跑现有自动备份契约,防止共用 helper 回归。
- [x] 更新现有试运行与自动化验证手册、部署 runbook/checklist 中的安装、只读检查、受控演练和回滚步骤;不新建重复运维文档。
## 2. Expected Files
- `scripts/`:应用健康探测器与共用企微告警 helper 小范围扩展。
- `deploy/trialrun/`:独立 ARM 安装器及既有 runbook/checklist 更新。
- `scripts/qa/`:应用健康契约测试与最小门禁接入。
- `docs/试运行与自动化验证手册.md`:操作与验收边界更新。
若实现时发现需要修改 `/ready`、Compose、数据库、Caddy 配置或企业微信业务接口,必须退回规划并重新批准,不得顺手扩大范围。
## 3. Validation
- [x] `python3 -m py_compile <新增探测器> scripts/send-wecom-group-alert.py`
- [x] `bash -n <新增安装器> scripts/qa/<新增契约测试>.sh`
- [x] `bash scripts/qa/<新增契约测试>.sh`
- [x] `bash scripts/qa/run-automatic-backup-contract.sh`
- [x] `node deploy/trialrun/validate-env-contract.js`(证明未引入部署 env 漂移)
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `python3 ./.trellis/scripts/task.py validate 07-27-app-health-monitoring`
- [x] `git diff --check`
## 4. Review Gates
- [x] 检查所有告警文本只包含状态、类别和恢复卡提示。
- [x] 检查首次瞬时失败不发送、第二次持续失败发送、重复失败降噪、恢复只发送一次。
- [x] 检查 helper 共用状态扩展不改变备份告警行为。
- [x] 检查安装器默认不 enable、不发送真实消息,卸载不删除共用 helper/webhook 或备份能力。
- [ ] 实际 ARM 安装、启用、真实告警演练和公网检查另行取得明确批准。
## 5. Rollback Points
- 代码回滚:删除独立探测器/安装器/契约测试,恢复 helper allowlist 与现有文档段落。
- 远端回滚:先 disable/stop 应用健康 timer,再运行该安装器的 uninstall;保留脱敏状态证据供复盘。
- 不回滚或删除:自动备份 receiver、备份 dead-man、备份归档、共用 webhook 文件。
@@ -0,0 +1,69 @@
# 应用健康监控
## Goal
为内部 OA 建立低维护、可降噪、可恢复通知的持续健康探测,使应用不可用、数据库不可达或公网 HTTPS 链路异常能够在无人访问时被及时发现,而不引入 Prometheus 等不符合 5–10 人团队维护成本的基础设施。
## Scope
- 在现有 ARM 异机侧每 5 分钟从公网探测 OA `/ready`。
- 新增独立探测脚本、独立 systemd 安装器和无需真实企微的契约测试。
- 小范围扩展现有企微群机器人 helper,使其支持应用健康故障、降噪与恢复状态。
- 更新现有试运行手册、部署 runbook/checklist;代码实现完成后,远端安装、启用和真实告警演练仍需单独批准。
## Background and Confirmed Facts
- `server/src/app.js` 已提供 `/health`、`/ready` 和按配置启用的 `/metrics`;`/ready` 会查询数据库,失败时返回 503,成功响应包含脱敏的构建标识。
- `deploy/trialrun/docker-compose.yml` 已有数据库与应用容器 healthcheck,但容器 healthcheck 本身不会向管理员发送通知。
- `deploy/prometheus/alerts-slo.yml` 已定义可用性、延迟和 5xx 告警规则,但当前试运行 Compose 未部署 Prometheus/Alertmanager,因此规则没有持续执行者。
- 现有部署、live smoke、WARCH3/WAUTO1 等脚本会调用公网 `/ready`,但它们是发布验证或每日业务/归档巡检,不是高频、独立的应用可用性看门狗。
- `scripts/send-wecom-group-alert.py` 已实现 webhook 文件权限校验、脱敏状态消息、相同故障降噪和故障恢复通知;当前允许的状态类别仅覆盖备份/异机 dead-man,需要为应用健康状态显式扩展或抽取兼容合同。
- `deploy/trialrun/install-warch3-ops-timer.sh` 和 `deploy/trialrun/install-automatic-backup-timer.sh` 提供了 systemd oneshot/timer、安装/卸载、路径校验和运行账户边界的可复用模式。
- OA 主机上的 timer 无法在整机宕机、主机断网或 timer 自身停止时发出告警;异机 ARM 探测可覆盖这些故障,但属于外部运行配置,实际安装与启用需要另行明确批准。
## Requirements
- R1:以公网 HTTPS `/ready` 为主要可用性信号,校验 HTTP 状态与响应中的 `data.ok=true`,不得记录完整响应或敏感 payload。
- R2:探测失败时生成有限、稳定、脱敏的故障类别,至少区分连接/TLS/HTTP/响应契约异常;不得把域名解析结果、地址、凭证、绝对路径或原始日志发送到群内。
- R3:相同故障连续发生时只发送一次告警;故障类别变化时允许重新告警;恢复后发送一次恢复通知。
- R4:使用 systemd oneshot + timer 运行,提供安全的安装、启用、状态检查和卸载路径;探测进程不得写 OA 数据库或企业微信业务数据。
- R5:告警复用现有企微群机器人安全入口及 0600 webhook 文件合同,不新增新的消息供应商或时序数据库。
- R6:提供无需真实 webhook 的契约测试,覆盖健康、连续相同故障降噪、故障变化、恢复通知、异常状态文件和隐私输出。
- R7:实际公网安装、systemd 变更、真实企微消息发送和远端配置不属于规划批准;执行前必须再次取得明确批准。
- R8:首版探测固定运行在现有 ARM 异机侧,每 5 分钟执行一次;连续两次失败才发送故障告警,一次成功即恢复并在此前确有已发送故障时发送一次恢复通知。
## Acceptance Criteria
- [x] AC1:定时探测可对健康公网 `/ready` 返回成功,并保留不含敏感信息的本地状态。
- [x] AC2:连接失败、TLS 校验失败、非 2xx/503、非法 JSON 或 `data.ok!=true` 均进入稳定的脱敏失败类别并使 oneshot 非零退出。
- [x] AC3:同一失败重复执行不会重复发送;失败类别变化会发送新告警;恢复后只发送一次恢复通知。
- [ ] AC4:ARM timer 每 5 分钟执行,单次探测有明确连接/总超时;持续故障在约 10 分钟内告警;安装后可用只读命令核对 enabled/active 及最近执行结果。
- [x] AC5:测试模式不访问真实企微,自动验证覆盖告警与恢复状态机、输入文件权限和消息脱敏。
- [x] AC6:现有备份告警、WARCH3 巡检、部署脚本和 `/ready` 合同不被破坏。
- [x] AC7:规划阶段不修改生产配置;若后续获批部署,公网独立探测和真实告警演练通过后才宣告监控可用。
本地实现已完成;AC4 保持未勾选并标记为 `EXTERNAL_GATED`,因为本任务未获 ARM 写入、timer 启用、真实企微消息或故障演练批准。仓库契约通过不代表监控已在运行。
## Out of Scope
- 部署 Prometheus、Alertmanager、Grafana 或通用可观测性平台。
- 业务登录、审批、报销等合成事务监控;首版只判断基础可用性。
- 自动重启、自动回滚、数据库修复或流量切换。
- TLS 证书提前到期提醒;首版通过标准 HTTPS 校验发现已经无效或不受信任的证书,提前到期预警留给后续独立评估。
- 收集或长期存储访问日志、响应 payload、主机地址及其他敏感运行数据。
- 本任务不处理组织架构、真实账号导入、发票图片附件或 OCR。
## Key Decisions
- 探测运行在现有 ARM 异机侧,而非 OA 主机,以覆盖 OA 整机、主机网络和本机 timer 同时失效。
- 每 5 分钟探测,连续两次失败确认持续故障;恢复只需一次成功。
- 首版复用现有企微群机器人告警入口,不部署 Prometheus,也不执行自动恢复。
- 应用健康安装器独立于备份 receiver 安装器,避免卸载或升级一个能力时意外影响另一个能力。
## Test Plan
- 静态检查 Python 与 shell 语法。
- 运行新增应用健康契约测试,覆盖健康、首次失败、持续失败、类别变化、重复降噪、恢复、异常状态和隐私边界。
- 复跑自动备份契约,证明共用企微 helper 行为未回归。
- 运行部署 env contract、流程基线、文档审计、Trellis 治理、task validate 与 `git diff --check`。
- 远端阶段另获批准后,先安装 disabled units,再手工健康探测,最后执行一次受控失败/恢复演练并核对 timer 状态。
@@ -0,0 +1,26 @@
{
"id": "app-health-monitoring",
"name": "app-health-monitoring",
"title": "应用健康监控",
"description": "在现有 ARM 异机侧持续探测 OA 公网就绪状态,并以脱敏、降噪、可恢复通知的企微告警及时发现服务中断。",
"status": "completed",
"dev_type": null,
"scope": "ARM off-host public readiness probe, deduplicated WeCom alerting, systemd timer installer, contract tests, and existing runbook updates",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-27",
"completedAt": "2026-07-27",
"branch": null,
"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,57 @@
# ARM 应用健康监控上线设计
## 1. Safety Boundary
上线只改变 ARM 上的应用健康探测器、两个 systemd units 和独立状态目录;共用 helper 会更新为仓库已通过备份回归的版本,但不修改 webhook、备份 receiver、dead-man、归档或 OA 主机。所有远端写操作通过非交互 sudo 执行;若预检不满足,任务停在只读阶段。
## 2. Source and Secret Provenance
部署源固定为工作提交 `bb5113c` 中的探测器、helper 和安装器。先确认三个部署源相对 `HEAD` 无未提交改动并复跑专项/备份合同;当前 Trellis 任务记录可按正常流程保持未提交。安装前后比较 SHA-256;不使用远端旧仓库或聊天中的粘贴内容。
webhook 文件路径从既有 `hyb-oa-offhost-deadman.service` 的 `ExecStart` 参数在 ARM 内解析。解析结果必须是绝对路径、普通非符号文件、root-owned、mode `0600`;流程从不读取文件正文,也不把路径打印到会话证据。
## 3. Rollout Sequence
```text
local contracts
-> ARM read-only preflight
-> reject pre-existing app-health artifacts
-> upload committed sources to temporary directory
-> sudo installer install (disabled)
-> verify hashes/permissions/units
-> manual real /ready baseline
-> sudo installer enable
-> verify timer
-> stop only app-health timer
-> direct probe #1 against localhost unreachable HTTPS (pending)
-> direct probe #2 (one real failure alert)
-> direct probe #3 (deduplicated)
-> start formal service against public /ready (one recovery)
-> start timer and verify final state
-> remove temporary directory
```
失败命令预期非零时必须显式捕获退出码,不能让 shell 把受控故障误判为 rollout 脚本崩溃。状态验证只读取允许字段:schema version、连续失败计数、稳定类别、告警状态和 helper 的 `sent=true|false`。
## 4. Drill Isolation
演练不改 unit、不改 DNS、不停止公网服务。timer 暂停后,以 root 直接执行已安装探测器,目标为 ARM 本机一个预先确认未监听的低风险 TCP 端口,超时 1 秒;其余参数使用正式状态目录、helper 和 webhook。这样覆盖真实 TLS/连接分类、失败阈值、helper、机器人和恢复链,同时不会影响 OA 用户。
正式恢复必须通过 systemd service 执行真实公网 `/ready`,从而同时验证已安装 unit。成功后恢复 timer,并再观察至少一次定时健康执行或核对最近成功时间。
## 5. Rollback and Alert Closure
- 安装前失败:无远端写入,仅清理临时目录。
- 安装后、真实告警前失败:disable/stop 并运行安装器 `uninstall`,保留独立状态证据和共用文件。
- 真实告警后失败:先移除故障输入并运行正式 service 争取发送恢复通知;成功后可决定保留或卸载。若恢复仍失败,timer 保持停用,明确报告群内告警未闭环,不伪造恢复。
- 任一路径都不删除共用 helper、webhook、备份 dead-man、receiver 或归档。
## 6. Evidence and Privacy
证据记录提交短哈希、布尔预检、文件哈希比对结果、固定权限、systemd enabled/active、稳定故障类别、连续次数和 helper 发送布尔值。不得保存 SSH 地址、IP、用户名、webhook、完整远端路径、原始 `/ready` 响应、journal 或企微 payload。
人工收件确认与自动链路证据分开:脚本显示发送成功不等于用户已经看到消息,最终 AC9 需用户确认。
## 7. Compatibility
安装器会更新共用 helper,因此上线前必须复跑 73 项自动备份合同。上线后只检查既有 dead-man unit/timer 仍存在且状态未变,不主动运行 dead-man,避免归档异常时产生额外真实告警。
@@ -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,59 @@
# ARM 应用健康监控上线执行清单
## 1. Local Gates
- [x] 确认 `HEAD` 包含工作提交 `bb5113c`,三个部署源相对 `HEAD` 无未提交改动;允许当前 Trellis 任务记录保持未提交。
- [x] 运行应用健康专项 22 项和自动备份兼容回归 73 项。
- [x] 记录三个部署源文件的 SHA-256,不输出其他本地路径或配置。
## 2. Read-only ARM Preflight
- [x] 通过受控 alias 连接,确认非交互命令可用;首次 sudo 门禁停止,用户建立临时入口后复检通过。
- [x] 确认 Python 3、systemd、既有 off-host dead-man service/timer 和共用 helper 存在。
- [x] 在 ARM 内从 dead-man unit 解析 webhook 文件路径,并只返回 owner/mode/regular-file 布尔结果。
- [x] 确认公网 `/ready` 从 ARM 可达,且应用健康 unit、探测器和状态目录尚不存在;若存在则停止人工审查。
- [x] 记录既有 dead-man timer enabled/active 基线,后续只做对比,不主动执行。
## 3. Disabled Installation
- [x] 在 ARM 创建本次唯一临时目录,以 SCP 传输探测器、helper 和安装器并核对 SHA-256。
- [x] 用 root 运行安装器 `install`,webhook 路径只在远端 shell 变量中传递。
- [x] 确认 timer disabled/inactive、service inactive,unit 为 5 分钟、状态目录 0700、受管文件 root:root 0755。
- [x] 比较已安装探测器/helper 与提交源哈希一致。
- [x] 手工启动正式 service,确认健康、probe/alert state 为 0600 且没有发送恢复噪声。
## 4. Enable and Real Drill
- [x] 用同一提交源执行安装器 `enable`,确认 timer enabled/active。
- [x] 停止应用健康 timer;确认不影响备份 dead-man timer。
- [x] 确认选定 localhost 演练端口未监听。
- [x] 第一次直接失败探测:预期非零、`consecutiveFailures=1`、alert pending。
- [x] 第二次直接失败探测:预期非零、helper `sent=true`、alert state 为 `FAIL_APP_HEALTH`。
- [x] 第三次直接失败探测:预期非零、helper `sent=false`,证明相同类别降噪。
- [x] 运行正式 systemd service 恢复:预期成功、helper `sent=true`、alert state 为 `PASS_APP_HEALTH_READY`。
- [x] 再运行一次健康探测或等待 timer:预期 helper `sent=false`。
- [x] 重新启动 timer,确认 enabled/active、最近成功和下一次不超过 5 分钟。
## 5. Final Evidence and Cleanup
- [x] 确认既有备份 receiver/dead-man 文件和 timer 状态未改变。
- [x] 删除本次 ARM 临时传输目录和临时 sudoers 规则,不删除状态、helper、webhook 或备份能力。
- [x] 仅记录脱敏状态、类别、计数、哈希匹配和 timer 布尔证据。
- [x] 用户已确认群内实际收到一条故障告警和一条恢复通知。
- [ ] 更新 PRD 验收项、运行 task validate、治理检查和 `git diff --check`。
## 6. Rollback Points
- [x] 首次 sudo 预检失败时未进入写阶段;用户建立受控临时入口后重新从完整预检开始。
- [ ] 安装后、故障告警前失败:disable/stop 后 `uninstall`,保留状态和共用能力。
- [ ] 故障告警后失败:优先正式健康探测关闭告警;无法恢复则保持 timer 停用并明确报告未闭环。
- [ ] 回滚后复核备份 dead-man timer 与归档仍在,不主动触发 dead-man。
## 7. Commands to Validate
- `bash scripts/qa/run-app-health-monitor-contract.sh`
- `bash scripts/qa/run-automatic-backup-contract.sh`
- `python3 ./.trellis/scripts/task.py validate 07-27-arm-app-health-rollout`
- `node scripts/qa/check-trellis-governance.mjs`
- `node scripts/qa/check-sensitive-content.mjs`
- `git diff --check`
@@ -0,0 +1,81 @@
# ARM 应用健康监控上线
## Goal
把已通过本地契约的公网 `/ready` 健康监控安全部署到现有 ARM 异机,启用 5 分钟 timer,并通过一次不影响 OA 用户的真实告警/恢复演练证明“持续故障能发现、重复故障会降噪、恢复会通知”。
## Scope
- 通过现有受控 SSH alias 连接 ARM;先做只读身份、sudo、systemd、既有备份 dead-man 和 webhook/helper 合同预检。
- 只向临时目录传输已提交版本的探测器、共用告警 helper 和独立安装器;校验源/目标哈希,不传输 webhook 正文。
- 复用既有 dead-man unit 中已经配置的 root-owned `0600` webhook 文件路径;路径只在 ARM 内解析和校验,不回显其内容。
- 先执行 `install` 安装 disabled units,核对精确受管目标、权限和 unit 合同,再手工运行一次真实公网健康探测。
- 健康基线通过后启用 5 分钟 timer;演练期间暂时停止 timer,使用同一已安装探测器、helper、webhook 和生产状态目录对本机不可达 HTTPS 端口连续执行三次:第一次 pending、第二次发送真实故障告警、第三次验证降噪。
- 随后通过正式 systemd service 探测真实公网 `/ready`,发送一次恢复通知,再恢复 timer 并核对 enabled/active、最近结果和下一次计划时间。
- 将输出压缩为脱敏状态、类别、计数、布尔检查和提交哈希;不记录 webhook、地址、原始响应、完整 journal 或远端绝对配置路径。
## Confirmed Facts
- 仓库提交 `bb5113c` 已实现并通过应用健康专项 22 项、自动备份兼容回归 73 项、后端 1018 项及前端构建。
- 安装器固定受管探测器、helper、状态目录和 systemd unit 名;`install` 默认 disabled,`uninstall` 保留共用 helper、webhook 和状态证据。
- ARM 已有 SSH alias,但当前 alias 不是 root;上线前必须证明非交互 sudo 可用,否则停止,不尝试口令或变更账户。
- webhook 不在仓库;既有 off-host dead-man service 是复用其路径的唯一事实源。
- 本任务已获用户确认:允许 ARM 安装、timer 启用、真实企微故障/恢复演练,并在失败时回滚应用健康组件。
## Requirements
- R1:任何写入前先完成只读预检;SSH 身份、`sudo -n`、既有 dead-man unit、root-owned `0600` webhook、Python/systemd 和公网 `/ready` 任一不满足即停止。
- R2:不得读取、打印或复制 webhook 正文;只允许把 ARM 内部已验证的 webhook 文件路径传给 root 安装器。
- R3:只安装提交版本的三个文件,并以 SHA-256 比对证明传输和受管目标一致;三个部署源相对 `HEAD` 必须无未提交改动,Trellis 任务记录可按正常流程保持未提交;不得从远端旧副本部署。
- R4:首次安装发现应用健康 unit、探测器或状态已存在时停止并报告,不覆盖未知运行状态;只有确认是本任务中刚创建的对象后才继续。
- R5:手工健康探测必须在启用 timer 前成功,且只保留稳定状态字段;不得用本地成功代替 ARM 公网链路成功。
- R6:真实故障演练不得停止 OA、Caddy、数据库、备份 receiver 或 dead-man;只使用 ARM 本机确定不可达的 HTTPS 端口,并在演练期间停止应用健康 timer 避免竞态。
- R7:第一次失败不得发送,第二次发送一条,第三次 helper 返回降噪;正式 service 恢复后发送一次恢复通知。
- R8:最终 timer 必须 enabled/active,最近一次 service 为成功,下一次计划不超过 5 分钟;备份 dead-man timer 仍保持原状态。
- R9:任一步失败时停止继续扩大变更。若尚未发送故障告警,卸载本任务组件;若已经发送故障告警,优先恢复正式探测并发送恢复通知,失败则保持 timer 停用并明确报告未闭环告警。
## Acceptance Criteria
- [x] AC1:只读预检证明受控 alias 可连接、`sudo -n` 可用、既有 dead-man/webhook/helper 合同成立,且未输出秘密或完整配置路径。
- [x] AC2:ARM 上安装的探测器和共用 helper 与提交版本哈希一致;unit/状态目录权限符合合同,install 后 timer 明确 disabled/inactive。
- [x] AC3:ARM 手工正式 `/ready` 探测成功,probe state 为健康且输出无地址、路径、payload 或原始异常。
- [x] AC4:timer 启用后为 enabled/active,调度周期为 5 分钟且最近正式执行成功。
- [x] AC5:受控演练证据显示第一次 pending、第二次 sent=true、第三次 sent=false;群内只产生一条脱敏故障告警。
- [x] AC6:正式 service 恢复成功且 sent=true,后续健康执行 sent=false;群内只产生一条恢复通知。
- [x] AC7:备份 receiver、dead-man unit/timer、归档与状态均未被卸载或覆盖;备份兼容合同在上线前仍通过。
- [x] AC8:临时传输目录已清理;最终只保留已批准受管文件、独立状态和 units,工作树与审计记录不含秘密。
- [x] AC9:用户确认实际收到一条故障告警和一条恢复通知;在此确认前只报告“自动链路证据通过,人工收件待确认”。
## Out of Scope
- 修改 OA、Caddy、数据库、DNS、证书、Compose 或公网部署。
- 停止真实服务来制造故障。
- 修改备份 receiver/dead-man 配置、归档、timer 或 webhook 内容。
- 新建机器人、变更企业微信配置、发送业务数据或原始日志。
- 增加证书到期预警、业务登录/审批合成监控或第三方 dead-man。
## Test Plan
- 上线前复跑 `bash scripts/qa/run-app-health-monitor-contract.sh` 与 `bash scripts/qa/run-automatic-backup-contract.sh`。
- ARM 只读预检、源/目标哈希、权限、unit 内容和 state schema 检查均返回有限布尔/状态 token。
- 先 disabled 安装和健康手工执行,再 enable;演练用同一正式状态链完成 pending/send/dedup/recovery。
- 结束后检查 timer、service、备份 dead-man 保持情况、临时目录清理、Trellis/task 治理和 `git diff --check`。
## Key Decisions
- 使用“不影响 OA 的本机不可达端口”制造监控输入故障,不停止任何真实服务。
- 复用正式状态目录做一次完整状态机演练,timer 在演练窗口暂停,避免健康执行插入失败序列。
- webhook 路径从既有 dead-man unit 在 ARM 内解析,正文始终不读取、不传输、不回显。
- 用户已批准操作范围,但 Trellis 仍要求本规划摘要之后再明确批准实施。
## Execution Evidence
- 2026-07-27:本地应用健康专项 22 项、自动备份兼容回归 73 项通过;三个部署源相对 `HEAD` 无未提交改动,SHA-256 已记录为脱敏技术证据。
- 2026-07-27:受控 SSH alias 可连接,但首个 `sudo -n true` 预检返回“需要密码”;流程按 R1 在任何远端写入前停止。
- 未创建 ARM 临时目录、未上传文件、未修改 systemd、未读取 webhook、未发送真实企微消息。
- 本地 SSH 配置中未发现指向同一 ARM 的 root alias。继续执行需要用户在 ARM 侧提供非交互 root 运维入口;不得在聊天中发送 sudo/root 密码。
- 2026-07-27:用户在自己的终端建立临时 sudoers 入口后,完整只读预检通过;disabled 安装、提交哈希、权限和 ARM 公网健康基线均通过,健康基线未发送消息。
- 2026-07-27:timer 已启用;隔离演练依次得到 pending、`sent=true`、`sent=false`,正式 systemd service 恢复成功并更新为 `PASS_APP_HEALTH_READY`,重复健康为 `sent=false`。
- 2026-07-27:最终 timer enabled/active、下一次运行在 5 分钟内、最近 service 成功;备份 dead-man enabled/active 未变。临时上传目录和临时 sudoers 规则均已删除。
- 自动链路已证明一条故障和一条恢复发送成功;随后由用户完成实际群内收件确认。
- 2026-07-27:用户确认企微群实际收到一条“内部 OA 运维告警”和一条“内部 OA 运维恢复通知”,真实收件闭环完成。
@@ -0,0 +1,26 @@
{
"id": "arm-app-health-rollout",
"name": "arm-app-health-rollout",
"title": "ARM 应用健康监控上线",
"description": "在现有 ARM 异机安全安装并启用公网 /ready 健康监控,通过不影响 OA 用户的真实企微故障、降噪和恢复演练完成上线验收。",
"status": "completed",
"dev_type": "operations",
"scope": "deployment",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-27",
"completedAt": "2026-07-27",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "本地 22+73 项合同通过;首次 sudo 预检安全停止。用户建立临时入口后完成 ARM disabled 安装、启用、真实故障/降噪/恢复演练及最终健康核对,临时上传和 sudoers 已删除;用户确认实际收到一条故障和一条恢复通知。",
"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,85 @@
# 每日自动异机备份设计
## 总体边界
本任务不建设备份平台。生产主机负责生成和推送一个不可变成对批次,ARM 用受限 SSH 接收身份把传输区与归档区隔离;systemd 负责每日执行、缺失监测和季度提醒。
```text
production 02:30
resolve active Compose identity
-> flock
-> backup-all.sh (DB + storage + manifest)
-> local verify
-> rsync to ARM inbox/<run-id>.partial
-> restricted finalize <run-id> <manifest-sha>
-> ARM verify exact files/manifest/hash
-> atomic move to archive/<run-id>
-> daily + weekly retention
-> write safe success state
-> prune local daily copies
production 07:10 watchdog
-> recovery point missing/corrupt/stale/project drift ? fail + group alert
-> build SHA differs ? one finding + group alert
ARM independent watchdog
-> newest finalized archive <= 26h ? pass : direct group alert
-> source host/server/container not required
quarterly reminder
-> one redacted WeCom reminder per calendar quarter
-> human runs ARM shadow restore separately
```
## 生产备份编排
- 新增一个宿主 wrapper,所有正式输入由安装器写入 systemd:仓库根、Compose project/file/env、活动 PGDATA/附件目录、完整/短构建 SHA、备份根、状态根、SSH alias/key/known-hosts 和告警开关。
- wrapper 通过 Compose label 解析唯一 server/db 容器,并把实际 DB/附件 mount、server build SHA 与安装时冻结值逐项比较。任一漂移在调用 `backup-all.sh` 前停止。
- DB 名称/用户和密码只从活动容器环境在内存中取得,传给现有 Docker 备份路径;不得 source 漂移的旧部署 env,不得输出取值。
- 每个自动批次使用 `auto-<UTC timestamp>`,源端根和状态根均为显式安全绝对路径、权限 `0700`。`flock` 拒绝同一任务重入。
- 只有 ARM finalize 成功后才写 `last-success.json` 并清理源端旧日批次。失败保留本次完整源批次供重试,不产生异机成功状态。
## ARM 受限接收边界
- ARM 一次性安装 rsync,创建密码锁定的专用账号、独立 inbox/archive/weekly 目录和一把专用公钥。该公钥的 `authorized_keys` 强制执行仓库提供的 receiver,并禁用 PTY、端口转发、agent 转发和 X11 转发。
- receiver 只接受两类 `SSH_ORIGINAL_COMMAND`:rsync server 接收模式,或 `hyb-backup-finalize <run-id> <manifest-sha256>`。其他命令全部拒绝。
- rsync 通过 `rrsync -wo` 只能进入 inbox;archive 和 weekly 位于 rrsync 根之外。即使生产端密钥泄露,也不能用 rsync 读取或删除已归档批次。
- finalize 只接受 `auto-` 安全批次号和完整 manifest SHA。它要求 inbox 中恰有三个普通文件、无链接,manifest 的 runId/status/schema、相对文件名、大小和 SHA-256 全部匹配;同 run_id 重复 finalize 继续保持幂等。
- 同 UTC 日已有不同 run_id 时,先完整校验并冻结新批次权限,再把旧日归档 rename 到同文件系统临时回滚名、新批次 rename 到正式归档名;新批次发布失败立即把旧归档 rename 回原名。新批次发布成功后才删除旧回滚名,并重建该 ISO 周硬链接检查点,使每日一个恢复点和周检查点都指向当前有效批次。
- archive 保留最近 7 个日批次;每个 ISO 周首次成功批次用只读硬链接快照形成周检查点,保留最近 4 周。清理不得删除最后一个 archive 或当前 finalize 批次。
- 旧手工/审计批次不进入自动前缀和自动轮转范围。
## 传输与主机身份
- 生产 root 使用一把仅用于本接收器的私钥,权限 `0600`;SSH config 使用专用 alias,known_hosts 由人工核对的固定主机指纹生成,timer 固定 `StrictHostKeyChecking=yes`,不得使用 `accept-new`。
- rsync 只发送完整源批次到同名 `.partial` inbox。网络中断可续传同一 partial;远端 archive 同名存在时 finalize 失败,不覆盖。
- 仓库、任务和报告只记录 alias 类别、布尔值和批次数量,不记录地址、账号、密钥、公钥原文或完整运行目录。
## 告警与静默失败检测
- 新增一个小型宿主告警 helper,使用 Python 标准库从 root-only Webhook 文件读取完整 URL,并向企微运维群机器人发送固定白名单文本;不复用 OA server 容器、员工表或个人工号。
- 生产 wrapper 无论成功或失败都生成字段白名单 summary;即时失败和源端 watchdog 直接调用宿主 helper。Webhook 文件缺失、权限不是 `0600`、不是普通文件或内容格式不合法时 fail closed,并在本机诊断中留类别,不打印 URL。
- ARM receiver 安装器同时安装独立 dead-man service/timer。watchdog 读取 archive 中最新有效自动批次的完成时间,超过 26 小时、没有批次或 manifest 无法验证时直接调用同一 helper;状态文件实现首次失败、重复降噪和恢复通知。
- 生产 stale watchdog 仍验证 schema/status/project/时间。build SHA 不同输出 `PASS_WITH_FINDINGS`,不返回失败;恢复点缺失、损坏、未来时间、超过阈值或 project 不同继续返回失败。
- 生产外部命令的 stdout/stderr 只写状态目录下的临时诊断,失败时原子保留为 `last-error.log` 并设为 `0600`,成功后清理本次临时诊断。summary 和企微文本不得复制诊断正文。
- Webhook secret 同时存在于两台主机的 root-only 文件,仓库只保存“文件路径”合同;`.env.example` 和 systemd `Environment=` 都不得出现 URL/key。
- 季度提醒以年份+季度作为 finding key,确保每季度一次且同季度不重复;只提醒人工影子恢复,不执行恢复。
## 安装与回滚
1. 本地先实现合成测试、受限 receiver、告警适配和两个安装器,不接触生产/ARM。
2. 获批后先由用户在企微创建运维群机器人,并分别把同一 Webhook 写入两台主机 root-only 文件;不向 Codex、仓库或普通 `.env` 提供其内容。
3. 在 ARM 安装 rsync、受限账号/receiver/公钥、dead-man timer 和目标目录;使用无业务数据的合成批次验证拒绝 shell、同日安全取代和 dead-man 状态逻辑。
4. 再在生产安装专用私钥/known_hosts、每日备份 service/timer、stale watchdog 和季度提醒;首次安装先保持 timer disabled。
5. 手工启动一次获批自动备份,核对源/ARM 哈希、状态和无敏感输出;再 enable timers。
6. 真正停止一次源端 timer 或故意让一次传输失败,等待/模拟超过 26 小时并确认 ARM 群告警;恢复链路后验证恢复通知和新批次成功。
回滚只 disable/stop 新 timer、移除 unit 和专用密钥授权;保留已生成的完整备份、状态报告和 ARM archive,不删除历史数据。旧手工 `backup-all.sh` 入口保持兼容。
## 取舍
- 选择生产推送而非 ARM 拉取,是为了让一次任务同时掌握生成、传输、目标校验和即时告警结果。
- 不给生产普通 ARM shell,而用 inbox + 强制 receiver + archive 隔离,抵消推送方案最主要的跨机删除风险。
- 使用 rsync 而不是新备份服务;当前压缩批次规模较小,收益主要是断点续传和成熟 SSH 传输,不追求块级去重。
- 不自动恢复。季度人工影子恢复才是可恢复性证据,日 timer 只负责生成恢复点。
- 运维加固在 dead-man、真实失败链路、timer 和现有 runbook 恢复卡完成后硬收口;公司只有一名系统管理员,季度影子恢复由该管理员按卡自查,不设置第二人走查门禁。后续不继续新增平台化备份或告警能力,回到产品 backlog。
@@ -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,85 @@
# 每日自动异机备份实施计划
## 实施步骤
- [x] 实现宿主自动备份 wrapper:安全绝对路径、唯一 Compose 来源、活动 mount/build 校验、`flock`、成对备份、ARM 推送/finalize、成功状态和源端 7 日保留。
- [x] 实现 ARM 受限 receiver:只允许 rrsync 写 inbox 和 finalize,严格校验三文件 manifest/大小/哈希、原子归档、7 日 + 4 周保留和最后成功保护。
- [x] 实现生产与 ARM 安装器:专用账号/密钥/known_hosts 边界、systemd daily/watchdog/quarterly units、安装/卸载幂等和默认不触发真实任务。
- [x] 泛化现有企微运维告警的 stdin summary、issueId 和备份建议,使用独立状态文件并补单元测试。
- [x] 增加合成契约测试:来源漂移、锁冲突、不完整/损坏/重复批次、续传 partial、拒绝普通 shell、保留轮转、stale 和季度降噪。
- [x] 更新部署环境契约、runbook、checklist、管理员手册的最小操作入口和 `.trellis/spec/hyb-oa/deployment-trialrun.md`;不新增平行长文档。
- [x] 生成一页脱敏恢复卡内容并放在现有 runbook 的首部紧急段;由唯一系统管理员维护并用于季度自查,不另建知识库副本或第二人走查门禁。
- [x] 完成本地全量门禁并提交;生产、ARM 安装另行请求批准。
- [x] 获批后先安装 ARM 接收端并用合成批次验证,再安装生产 units,手工运行首次真实自动批次,最后 enable timers。
- [x] 获批执行一次可控源端停止告警并恢复正常链路;不读取附件正文、不执行业务或企微其他写入。
- [x] 删除个人工号默认值,新增生产/ARM 共用的 root-only 企微群机器人 helper;两端 systemd 只绑定 Webhook 文件路径。
- [x] 增加 ARM 26 小时 dead-man service/timer,覆盖缺批次、超龄、损坏、重复降噪和恢复通知。
- [x] 将 buildSha 不同降级为 finding;缺失/损坏/超龄/project 不同继续硬失败,并支持部署后立即运行一次备份。
- [x] 为 backup/rsync/finalize 保存 `0600` 最近失败诊断,summary 与群消息只保留脱敏类别。
- [x] 把同 UTC 日不同 run_id 从拒绝改为“完整校验后原子取代”,覆盖失败回滚和周检查点重建。
- [x] 增加 Webhook 不进入 `.env.example`/仓库配置/报告的否定断言,并在 runbook 记录调整 `BACKUP_RUN_AT` 后需人工确认次日窗口。
- [x] 本地门禁通过并完成只读复核后提交;不推送、不部署、不配置真实 Webhook。
## 预计文件边界
- 新增 `scripts/run-automatic-backup.sh`:生产 host 编排与 safe summary。
- 新增 `scripts/receive-offhost-backup.sh`:ARM forced-command receiver、finalize 和 retention。
- 新增 `deploy/trialrun/install-automatic-backup-timer.sh`:生产 units 安装/卸载。
- 新增 `deploy/trialrun/install-offhost-backup-receiver.sh`:ARM rsync/账号/目录/公钥安装检查;软件安装和账号创建只在获批远端阶段执行。
- 新增 `scripts/qa/run-automatic-backup-contract.sh`:纯合成正负向测试。
- 修改 `server/src/scripts/send-wecom-ops-alert.js` 及其单测,仅做通用脱敏 summary 兼容。
- 修改 `deploy/trialrun/validate-env-contract.js`、现有 runbook/checklist/管理员手册和部署规约。
- 新增一个不含 Secret 的宿主告警 helper;只读取安装时传入的 root-only Webhook 文件。
## 验证命令
- `bash -n scripts/run-automatic-backup.sh scripts/receive-offhost-backup.sh deploy/trialrun/install-automatic-backup-timer.sh deploy/trialrun/install-offhost-backup-receiver.sh scripts/qa/run-automatic-backup-contract.sh`
- `bash scripts/qa/run-automatic-backup-contract.sh`
- `bash scripts/qa/run-backup-restore-drill.sh`
- `cd server && node --check src/scripts/send-wecom-ops-alert.js`
- `cd server && npm test -- --run tests/unit/scripts/send-wecom-ops-alert.test.js`
- `node deploy/trialrun/validate-env-contract.js`
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/07-27-automatic-offhost-backup`
- `node scripts/qa/check-trellis-governance.mjs`
- `node scripts/qa/check-sensitive-content.mjs`
- `node scripts/qa/audit-docs.mjs --check-registry`
- `node scripts/qa/check-process-contract.mjs --baseline --json`
- `git diff --check`
- 合成测试必须模拟源端不再产生 finalize,并由 ARM dead-man 得到失败;不能只直接调用“发送消息”路径。
## 外部门禁与回滚点
- 安装 ARM rsync/账号/authorized_keys/目录属于远端配置写入,须单独批准;不得复用普通登录账号或本地工作站私钥。
- 安装生产 SSH key/config/systemd units、首次真实备份、真实异机传输和企微失败告警均须单独批准。
- 创建企微运维群机器人及把 Webhook 写入两台主机 root-only 文件由用户手工完成;Codex 不读取、不回显、不记录其内容。
- 首次真实执行前记录现有手工批次数量、磁盘余量、活动容器/mount/build 和现有 timer;只输出脱敏计数/布尔值。
- 任何目标身份、主机指纹、活动 mount、manifest 或哈希不匹配立即停止,不猜路径、不接受新指纹、不清理源端批次。
- 回滚新 timer/receiver 时只撤销调度和专用授权;ARM archive、源端完整批次和原 WARCH3 timer 保留。
## 本地实施与验证证据
- 已实现生产 wrapper、ARM forced receiver、独立群机器人 helper、ARM dead-man 和两端安装器;生产 3 组 timer 与 ARM 1 组 timer 均默认只安装为 disabled。
- `run-automatic-backup-contract.sh` 使用临时目录与 mock Docker/SSH/rsync 完成 46 项正负向合同,不连接真实主机;覆盖同日取代与回滚、坏候选保留、周检查点重建、Webhook 权限/链接/格式负例、build finding、状态损坏、源端停止、重复降噪和恢复通知。
- 企微告警聚焦单测 15 项通过;stdin 路径被替换为安全类别,AUTO_BACKUP 首次失败/季度提醒发送且相同重复状态降噪。
- `run-backup-restore-drill.sh` 本地合成 DB/附件往返通过,本次合成 RTO 为 8 秒;该结果不是生产 RTO 或 ARM 恢复证据。
- `scripts/check-minimal.sh` 通过:server 162 个测试文件、1004 项测试全部通过,client typecheck/build 通过。
- 环境合同、Trellis task/governance、敏感扫描、文档注册、流程合同和 `git diff --check` 均通过;文档注册保留既有 22 个未注册文件 warning,无本任务新增文档。
- 最终只读复核修正了三个本地缺陷:生产安装器残留个人工号校验、ARM 重装可能改变 dead-man 状态文件属主、stale 状态未校验 run id/manifest SHA;修正后 46 项合同与 `scripts/check-minimal.sh` 全量复跑通过。
- 更新后的 ARM dead-man/helper、生产 helper/runner、真实有效首批、四组 timer enable 与真实企微失败链路已在单独授权后完成;公司只有一名系统管理员,第二人季度走查不适用。
### 首次远端执行增量
- ARM 已安装 rsync、密码锁定专用账号和 forced-command receiver;从生产验证普通 shell、sender/read、delete 均拒绝,写通道 dry-run 通过且未生成归档。
- 生产三组 units 已安装为 disabled/inactive,并绑定活动 Compose project、PGDATA、附件挂载、构建标识和固定 SSH 身份。
- 首次真实备份曾在 `runtime_identity` 阶段因 Docker `println` 参数间空格 fail-closed;runner 改用 `docker inspect` mount JSON 后,后续真实源端成对批次已生成并传输。
- ARM finalize 随后因同 UTC 日已有不同 run id 而拒绝,因此尚无可作为自动备份闭环证据的新批次;本轮已在本地实现“完整校验后同日取代”,更新后的远端脚本、首批和 timer enable 仍待另行批准。
### 远端启用与真实告警验收增量
- 获批部署 `a0006f0` 后,生产活动构建与预期一致;自动备份同日新批次经 ARM 完整校验后安全取代旧日批次,源端与目标端 manifest、文件大小和 SHA-256 一致,最近失败诊断不存在。
- 生产 daily、watchdog、季度恢复提醒与 ARM dead-man 四组 timer 均为 `enabled/active`;最终只读复核中四个对应 service 最近结果均为 `success`、退出码均为 `0`。
- daily 下一窗口为每日 02:30,ARM dead-man 为每日 06:30、26 小时阈值,生产 watchdog 为每日 07:10,季度恢复提醒为每季度首日 09:00;失败验收结束后 daily 已恢复正常调度。
- 两端企微运维群机器人均由用户手工写入执行账号持有的 root-only `0600` 文件;Codex 未读取、回显或记录 Webhook 内容。
- 真实失败链路通过停止源端 daily timer 并临时缩短 ARM 阈值触发:首次 dead-man 超龄只发送一条 `FAIL_OFFHOST_DEADMAN`/`archive_stale` 脱敏通知,相同失败重复执行明确降噪、不再次发送。
- 恢复 daily、生成新的真实有效批次并恢复 ARM 正常阈值后,dead-man 返回 `success/0`,企微群只收到一条 `PASS_OFFHOST_DEADMAN_FRESH`/`healthy` 恢复通知;临时失败测试脚本已删除。
- 本轮外部验收未读取附件正文、备份文件正文、原始日志或 Webhook 内容,未修改业务数据;恢复卡已保存在现有 runbook,由唯一系统管理员按季度提醒自行执行影子恢复。
@@ -0,0 +1,76 @@
# 每日自动异机备份
## Goal
为 5–10 人内部 OA 建立低维护的每日自动恢复点:生产每天生成同批数据库、附件和 manifest,完整校验后复制到 ARM 备份机,失败可被当天发现并告警;同时用简单保留策略和季度人工影子恢复降低主机故障与静默备份失效风险。
## Background
- 现有 `scripts/backup-all.sh` 已能生成 DB、附件和脱敏 manifest 的完整批次,并在发布完整目录前验证 dump、tar 和 SHA-256。
- 当前仓库没有任何生产调度器调用 `backup-all.sh`;唯一活动 timer 是 07:30 运维巡检,不执行备份。
- 生产主机和 ARM 均有 systemd、SSH、`flock` 和 SHA-256 工具;生产有 rsync,ARM 当前没有 rsync。
- 两台主机之间当前没有可直接复用的免密 SSH 别名或密钥链路,不能把本地工作站的 SSH 配置误当作服务器间自动化能力。
- 当前手工正确批次和 ARM 影子恢复已证明格式可用,但不能满足 RPO 24 小时或自动失败发现。
- 已确认采用“生产主动推送到 ARM 的专用受限接收身份”:生产保留即时传输结果,ARM 的已归档批次不暴露给 rsync 写入根。
- 任务实施前,生产切换已完成并应用 61 项 migration;本地为 62 项,`2526052` 后续部署只新增 1 项 migration。当时自动备份三组 units 已安装但尚未形成可靠启用闭环。
- 首次真实自动批次曾证明源端成对备份可生成,但 ARM 因“同一 UTC 日仅允许一个不同 run_id”拒绝重试批次;当时即时告警仍依赖活动 server 容器,不能覆盖容器或整机故障。
## Scope
- 生产每日成对备份、ARM 受限接收与目标端校验。
- 源端 7 个日批次、ARM 7 个日批次加 4 个周检查点的安全轮转。
- 每日 systemd 调度、独立 stale watchdog、失败企微告警和季度人工恢复提醒。
- 两端安装器、合成契约测试、现有运维文档/规约更新和一页脱敏恢复卡。
## Requirements
- 每日自动从唯一活动 Compose project 解析 DB 容器、活动附件挂载和实际构建标识,不读取旧项目、固定容器名或仓库默认附件目录。
- 复用 `backup-all.sh` 生成完整批次;任何来源核验、dump、tar、manifest 或哈希检查失败时不得进入异机完成态。
- 使用独立 systemd service/timer 和 `flock`,避免与部署、人工备份或自身上次运行重叠。
- 完整批次通过固定主机指纹和专用受限密钥自动推送到 ARM;该身份无普通 shell,只能把文件写入 inbox 或请求校验/归档,不能通过 rsync 读取或删除 archive。
- 传输端和目标端都要校验 manifest、文件大小和 SHA-256;同名已归档目标不得覆盖。中断传输可以在 inbox 内续传,但只有完整校验后才能原子进入 archive。
- 生产保留最近 7 个成功日批次;ARM 至少保留最近 7 个日批次和 4 个周检查点。清理只能发生在本次异机成功确认之后,且不得删除最后一个已知成功批次。
- 生产即时告警和 ARM dead-man 告警统一使用独立企微运维群机器人;Webhook 只从 root-only 文件读取,不依赖 OA server、数据库、员工绑定或默认个人工号,也不得进入仓库、`.env.example`、Trellis、日志或聊天。
- ARM 安装独立 26 小时 dead-man timer,直接检查最近归档完成时间;生产整机或容器不可用、timer 未运行、传输未 finalize 时仍能从另一台主机发出告警。
- 生产 watchdog 的“恢复点缺失/损坏/超龄/Compose project 不匹配”继续硬失败;仅 `buildSha` 早于当前部署时降级为一次 finding,不得误报为没有恢复点。部署后仍推荐立即触发一次获批备份。
- backup、rsync 和 finalize 的失败输出必须写入状态目录下权限 `0600` 的最近失败诊断;企微告警仍只包含阶段、状态、finding 数和脱敏建议,不附原始输出。
- 同一 UTC 日已有不同自动归档时,新批次必须先完整通过 manifest、文件集、大小和 SHA-256 校验,再以可回滚的同文件系统 rename 顺序原子取代旧日批次;任一替换步骤失败必须恢复旧批次或保留可诊断状态。最终仍只保留每日一个恢复点。
- 告警只包含阶段、状态、finding 数和脱敏建议,不包含数据库凭证、完整目录、SSH 信息、真实业务数据、附件名或原始命令输出。
- 季度恢复保持人工触发,在 ARM 隔离目标执行;季度 timer 只发送一次脱敏提醒,不得自动运行恢复,更不得触碰生产库。
- 形成一页脱敏恢复卡,说明主机故障后的负责人、备份位置类别、恢复顺序和验收项,保存在现有运维 runbook 中,由唯一系统管理员维护并用于季度自查。
## Acceptance Criteria
- [x] 本地合成测试证明每日 wrapper 只接受唯一活动来源、互斥锁有效,并只发布完整备份批次。
- [x] 传输测试证明不完整批次、哈希不一致、重复批次和不可达 ARM 均失败,且不产生异机成功状态。
- [x] 保留测试覆盖 7 个日批次、4 个周检查点、最后成功批次保护和仅在异机确认后清理。
- [x] 告警测试证明首次失败和状态恶化会告警,重复同一失败降噪;07:10 stale 检查能发现超过 24 小时没有异机成功记录。
- [x] 本地合成测试证明同日不同 run_id 的完整新批次可取代旧批次,损坏新批次不会影响旧归档,且周检查点与日归档不产生悬空或错误指向。
- [x] 本地合成测试证明 buildSha 不同仅产生 finding,恢复点缺失/损坏/超龄/project 不同仍失败;失败诊断为 `0600` 且不进入告警摘要。
- [x] 环境契约证明 Webhook 相关键和值不出现在 `.env.example`、仓库配置或报告;两端安装器只接受 root-only Webhook 文件路径。
- [x] ARM 26 小时 dead-man 合成验证覆盖“源端停止/不产生新 finalize”而非仅验证机器人能发送,并覆盖首次失败、重复降噪与恢复通知。
- [x] systemd 安装器显式绑定 Compose project、活动数据路径、构建标识、备份根和受限远端身份;安装后四组 timer 均 enabled/active,安装阶段未手工执行真实告警。
- [x] 经单独生产授权后完成一次自动日备份:源端完整、ARM 目标端哈希一致、状态报告脱敏,且未读取附件正文或修改业务数据。
- [x] 模拟源端停止产生一条脱敏企微告警,相同失败重复执行不再发送;恢复正常链路并完成成功批次后只发送一条恢复通知。
- [x] 恢复卡已写入现有运维 runbook,覆盖恢复入口、影子目标和 `/ready`/关键计数验收项;公司只有一名系统管理员,不设置虚构的第二人走查门禁。
## Out of Scope
- 不引入备份平台、对象存储、MinIO、云厂商 SDK、Kubernetes 或持续复制。
- 不自动执行生产恢复、数据库 reset、迁移改写或附件回写。
- 不把真实业务正文、附件清单、凭证、主机地址、完整目录或 SSH 私钥写入仓库、报告或聊天。
- 不把企微群机器人 Webhook URL、key 或配置文件正文写入仓库、`.env.example`、Trellis、报告、日志或聊天。
- 不在本任务继续企微本人授权、员工角色、付款、报销或其他产品功能。
- 不因自动备份完成而取消季度人工影子恢复。
## Test Plan
- Shell 语法与合成文件系统测试,覆盖锁、来源解析、传输失败、目标重复、哈希和保留轮转。
- 合成 receiver 测试覆盖同日完整批次取代、损坏候选保留旧批次、替换失败恢复以及周检查点一致性。
- 合成双主机告警测试覆盖生产无 server 容器、ARM 26 小时无新归档、重复降噪、恢复通知和 root-only Webhook 文件门禁。
- 复用 `scripts/qa/run-backup-restore-drill.sh` 验证成对备份合同未回退。
- 聚焦测试现有企微运维告警的备份状态与 stale finding 适配。
- `node deploy/trialrun/validate-env-contract.js`。
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/07-27-automatic-offhost-backup`。
- Trellis governance、敏感扫描、文档注册/流程合同和 `git diff --check`。
- 生产安装、首次自动批次、真实失败告警和 ARM 目标验证分别保持外部门禁。
@@ -0,0 +1,32 @@
# 自动异机备份运行拓扑调查
## 边界
- 调查日期:2026-07-27。
- 仅执行只读命令;未创建账号、密钥、目录、timer 或备份,未安装软件、传输数据或发送告警。
- 只记录工具可用性、批次数量和连接布尔值,不记录主机地址、账号、完整路径、凭证或业务数据。
## 仓库事实
- `scripts/backup-all.sh` 只被手工文档和合成恢复演练调用,没有生产调度入口。
- 完整批次由 `database.dump`、`storage.tar.gz` 和 `manifest.json` 组成;最终目录只在 dump/tar 可读取且 SHA-256 生成后发布。
- DB Docker 路径要求显式容器和数据库参数;附件路径要求显式 `STORAGE_DIR_HOST`,不存在历史默认回退。
- 07:30 WARCH3 timer 只运行每日巡检。现有告警 CLI 可读取通用 summary 的 `issueId`、`status`、`findingCount` 和报告目录,但帮助、状态文件和部分建议文案仍绑定 WARCH3,备份复用前需要小范围泛化或独立状态文件。
## 主机能力
| 能力 | 生产主机 | ARM 备份机 |
| --- | --- | --- |
| systemd / SSH / flock / SHA-256 | 可用 | 可用 |
| rsync | 可用 | 当前不可用 |
| 现成跨主机免密别名/链路 | 无 | 无 |
- 生产当前存在少量历史/手工批次,但没有定时策略。
- 正确异机批次此前已复制并完成影子恢复;本轮默认目录探测没有把它当作新自动化根,说明自动化必须显式配置目标根,不能猜历史位置。
## 设计影响
- 无论推送或拉取,都需要一次性安装/配置 ARM rsync 并建立专用受限身份,不能复用本地工作站密钥作为生产事实。
- 直接给生产保存 ARM 普通登录密钥虽然最省事,但会让生产失陷扩展为异机备份可删除,不应采用。
- 生产推送更容易在同一任务内获得传输退出码并触发即时告警;要用无交互 shell、仅允许新批次写入的受限接收身份,并让已归档批次脱离该身份的删除权限。
- ARM 拉取让生产主机没有 ARM 凭证,隔离更强;代价是调度和告警分散在两台机器,需要额外心跳或状态回传才能保证当天发现失败。
@@ -0,0 +1,26 @@
{
"id": "automatic-offhost-backup",
"name": "automatic-offhost-backup",
"title": "每日自动异机备份",
"description": "为内部 OA 建立每日成对备份、ARM 异机复制、保留轮转、失败告警与季度恢复提醒的低维护自动化基线。",
"status": "completed",
"dev_type": null,
"scope": "scripts/backup automation; deploy/trialrun backup receiver and systemd installers; server ops-alert adapter/tests; deployment runbook/checklist/spec evidence",
"package": null,
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-27",
"completedAt": "2026-07-27",
"branch": null,
"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,75 @@
# 异机备份最终收尾修复设计
## 边界与顺序
本任务只修三类已确认缺陷:凭证进入 argv、紧急恢复卡口径漂移、finalize 被强杀后的孤儿事务。receiver 的恢复入口固定为:校验配置 → 取得 `.finalize.lock` → 恢复或拒绝历史孤儿事务 → 校验本次批次 → 发布/取代 → 周检查点 → 保留轮转。没有锁时不得扫描或改变孤儿对象。
## 凭证传递合同
### 生产 wrapper
`capture_command` 仍负责将 stdout/stderr 写入 root-only 诊断,但不再把 `env` 当作被执行命令。调用方进入隔离子 shell,在子 shell 内导出 `DATABASE_URL`、Docker DB 身份、备份目录和构建标识,再调用:
```bash
(
export DATABASE_URL DB_TOOL_MODE DB_CONTAINER POSTGRES_DB POSTGRES_USER \
POSTGRES_PASSWORD STORAGE_DIR_HOST BACKUP_DIR BACKUP_RUN_ID BUILD_SHA \
SOURCE_IMAGE_DIGEST
capture_command paired_backup "$ROOT_DIR/scripts/backup-all.sh"
)
```
敏感值仍只存在于执行进程环境和内存,不写入命令 argv、诊断、summary 或日志。本任务不宣称对 root 或同执行身份读取 `/proc/<pid>/environ` 提供额外隔离。
### Docker DB 工具
生产 Docker 已证明无值 `-e VAR` 能继承客户端环境。三处 Docker 调用统一为:
```bash
PGPASSWORD="$POSTGRES_PASSWORD" docker exec [-i] -e PGPASSWORD \
"$DB_CONTAINER" <pg-command-without-password>
```
`PGPASSWORD` 的值不得出现在 Docker CLI 参数,mock 测试同时检查参数不含合成密码、容器命令能看到继承值。测试夹具用于启动命令的普通 `env KEY=value` 不在本轮机械改写范围。
## 日归档孤儿恢复
receiver 在锁内扫描 `.daily-replacement-YYYYMMDD.*`。每个孤儿必须是受控 archive 根的一层普通目录且非链接;从其 manifest 取得原 `runId`,执行与正式 finalize 相同的精确文件集、大小和 SHA-256 自校验,并验证日期与孤儿名一致。
| 现场 | 含义 | 正确动作 |
| --- | --- | --- |
| 孤儿存在且当日正式归档存在、两者均有效 | 新批次已换入,只缺最后清理 | 保留正式归档,删除孤儿 |
| 孤儿存在且当日正式归档缺失、孤儿有效 | 旧归档已移开,新批次尚未换入 | 按孤儿 manifest 的 `runId` 原子恢复旧正式归档 |
| 两者都在但当日正式归档校验不过 | 无法判断换入结果 | fail closed,两个都保留 |
| 孤儿无效、同日多个正式归档、同日多个孤儿、名称/manifest 日期不符或任一对象为链接 | 状态歧义或受污染 | fail closed,所有对象保留 |
恢复旧归档或确认新归档后,才允许继续处理本次 partial。任何删除前再次验证目标仍是刚才检查过的严格路径和类型。
## 周检查点孤儿恢复
周检查点是日归档的硬链接派生物,但仍不得猜测或直接删除。每个 `week-YYYY-Www.new.*` / `.rollback.*` 先按正式周名分组,要求每类最多一个普通目录且非链接。正式周点和 rollback 必须自校验 manifest,且 manifest 日期属于该 ISO 周;`.new` 是从未提交的 staging,内容可能因强杀而不完整,不能作为恢复来源,也不要求内容有效,但只有在另一个有效权威来源已经确定后才可清理。
| 现场 | 含义 | 正确动作 |
| --- | --- | --- |
| rollback 存在、正式周点缺失、rollback 有效 | 正式周点已移开,新点尚未换入 | 原子恢复 rollback 为正式周点;`.new` 路径/类型安全时删除 staging,不使用其内容 |
| rollback 存在、正式周点存在且正式点有效 | 新周点已换入,只缺清理 | 保留正式周点,删除 rollback;`.new` 路径/类型安全时删除 staging |
| 仅 `.new` 存在、正式周点存在且有效 | staging 未提交或残留 | 保留正式周点;`.new` 路径/类型安全时删除 staging |
| 仅 `.new` 存在、正式周点缺失 | 没有已提交周点 | 先找到该 ISO 周唯一有效的正式日归档来源;能唯一确定时删除 staging 并从该日归档重建,不能唯一确定时 fail closed 并保留 `.new` |
| 正式周点无效、rollback 无效、多个候选、日期/周不符或任一对象为链接 | 状态歧义或受污染 | fail closed,全部保留 |
若日归档恢复动作影响某 ISO 周,必须从最终选定且已验证的正式日归档重建该周检查点后才算 reconciliation 成功。周点恢复/重建失败时不删除日归档 rollback。
## 失败传播与可观测性
reconciliation 任一歧义返回非零,receiver 不处理本次 partial。源端 SSH finalize 随即在 `offhost_finalize` 阶段失败,`capture_command` 保留 `0600` 诊断,`on_error` 通过独立企微群机器人发送脱敏类别,且不写 `last-success.json`、不清理源端批次。若故障持续,ARM dead-man 在超龄窗口独立告警。该双层链路已经真实验证,本任务只用合成测试证明返回值和源端状态传播,不再次制造真实断链。
## 文档与单管理员恢复卡
runbook 标题改为“紧急恢复卡(单管理员离线版)”,正文只保留唯一系统管理员、ARM 隔离影子恢复、真实生产恢复另行批准和不粘贴敏感内容的约束。复制到 ARM 与离线副本是后续可选运维动作,不写死真实路径、不进入代码验收。
## 兼容、部署与回滚
- 不改变批次、manifest、receiver forced-command、timer、保留数量或告警 payload 合同。
- 本地提交后先停在外部门禁;推送、生产 runner/DB 脚本部署、ARM receiver 更新分别请求批准。
- 代码回滚只回滚本任务提交;已存在的正式归档、周检查点、partial 和孤儿对象均不删除。
- 完成后归档本任务并结束运维加固主线。
@@ -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,63 @@
# 异机备份最终收尾修复实施计划
## 实施步骤
- [x] 先扩展合成测试:记录 mock Docker argv 与继承环境,证明四条凭证路径不含合成密码/完整连接串且容器能收到 `PGPASSWORD`。
- [x] 修改 `run-automatic-backup.sh`,在隔离子 shell内导出成对备份变量后调用 `capture_command`,移除生产路径 `env KEY=value` argv。
- [x] 修改 `backup-db.sh` 和 `restore-db.sh` 三处 Docker 调用,使用前缀环境赋值配合无值 `-e PGPASSWORD`;不批量改写测试夹具。
- [x] 为 receiver 增加锁内 self-validation 与 orphan reconciliation helper,严格实现 `design.md` 的日归档和周检查点状态表。
- [x] 增加孤儿测试矩阵:明确恢复、明确清理、周点重建、非法类型/链接/重复候选/校验失败全量保留,以及 fail-closed 到源端状态的合成传播。
- [x] 更新 runbook 恢复卡标题和正文为单管理员口径;同步 `.trellis/spec/hyb-oa/deployment-trialrun.md` 的凭证 argv 与中断恢复稳定合同,不新建平行文档。
- [x] 运行聚焦与全量相关门禁,完成只读差异复核;不连接真实告警、不写 ARM/生产、不执行 migration。
- [x] 经用户确认后提交中文 commit;推送和生产/ARM 部署另行批准。
- [x] 部署验证完成后归档本任务,硬收口运维主线并返回产品 backlog;不得因本轮审查再新增运维加固 task。
## 预计文件边界
- `scripts/run-automatic-backup.sh`
- `scripts/backup-db.sh`
- `scripts/restore-db.sh`
- `scripts/receive-offhost-backup.sh`
- `scripts/qa/run-automatic-backup-contract.sh`
- 必要时扩展现有 `scripts/qa/run-backup-restore-drill.sh` 的 argv 断言,不新增平行 runner。
- `deploy/trialrun/runbook.md`
- `.trellis/spec/hyb-oa/deployment-trialrun.md`
- 当前 Trellis task 三份规划/证据文件。
## 验证命令
- `bash -n scripts/run-automatic-backup.sh scripts/backup-db.sh scripts/restore-db.sh scripts/receive-offhost-backup.sh scripts/qa/run-automatic-backup-contract.sh scripts/qa/run-backup-restore-drill.sh`
- `bash scripts/qa/run-automatic-backup-contract.sh`
- `bash scripts/qa/run-backup-restore-drill.sh`
- `node deploy/trialrun/validate-env-contract.js`
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/07-27-offhost-backup-final-fixes`
- `node scripts/qa/check-trellis-governance.mjs`
- `node scripts/qa/check-sensitive-content.mjs`
- `node scripts/qa/audit-docs.mjs --check-registry`
- `node scripts/qa/check-process-contract.mjs --baseline --json`
- `git diff --check`
## 外部门禁与回滚点
- 生产 Docker 无值 `-e VAR` 继承只读实测已完成;实施阶段不重复读取生产配置或凭证。
- 推送当前 3 个未推送提交与本任务提交,需要提交完成后另行批准。
- 更新生产自动备份脚本、ARM receiver、手工触发真实备份或复制恢复卡到 ARM,均需分别明确批准。
- 生产部署前只读确认活动构建、62 项 migration、四组 timer 和最近成功状态;本任务没有 migration。
- 任一 orphan 合成负例发生对象减少、正式恢复点丢失或歧义状态被自动选择,立即停止,不进入部署。
- 回滚只回退本任务脚本/文档,不清理现场备份、partial、rollback 或已归档 task。
## 本地实施与验证证据(2026-07-27)
- 凭证传递已覆盖 wrapper、nightly Docker 备份、Docker 影子确认和恢复;mock 捕获的 argv 不含合成连接串/口令,容器命令仍收到继承的 `PGPASSWORD`。
- receiver 合同共 73 项通过。除原有同日取代、保留轮转、dead-man 和失败传播外,新增日/周孤儿恢复、重复/链接/损坏歧义 fail closed,以及“周 reconciliation 失败时日 rollback 不提前删除”的回归断言。
- `run-backup-restore-drill.sh` 通过,隔离合成 RTO 为 9 秒;`validate-env-contract.js` 返回 `[R26][PASS]`。
- shell 语法、task validate、Trellis governance、敏感内容扫描、文档注册审计、流程 baseline 和 `git diff --check` 均通过。文档审计仅报告仓库既有 22 个未注册 docs 文件警告,本任务未新增 docs 文件。
- 只读复核发现并修复了“先删日 rollback、后重建周点”的顺序缺陷;稳定规则已同步到 `deployment-trialrun.md`。本地过程未连接真实企微、ARM 或生产,也未执行 migration。
## 受控远端更新证据(2026-07-27)
- `01f1259` 与此前三个收尾提交已推送到 `origin/main`,本地与远端分支一致。
- 经单独批准,生产端只原子替换自动备份 runner、数据库备份和影子恢复三份脚本;远端 SHA-256 与提交内容一致,shell 语法通过。三组自动备份 timer 保持 enabled/active,对应 service 均为 inactive/success;未手工启动备份。
- 经单独批准,ARM staging receiver 更新后由管理员通过既有 root 安装 wrapper 安装。安装器内部受限账户配置校验通过;正式 receiver 与提交内容 SHA-256 一致,属主/权限为 root:root `0755`,dead-man timer 保持 enabled/active,service 为 inactive/success。
- 本轮远端更新未触发真实备份、企微告警、数据库 migration 或业务数据写入;公网业务构建和 62 项 migration 基线不在本次脚本更新范围内。
- 至此本任务的代码、推送和远端验证均完成;按既定边界归档后结束运维加固主线,后续返回产品 backlog。
@@ -0,0 +1,62 @@
# 异机备份最终收尾修复
## Goal
在不扩建运维平台的前提下,完成自动异机备份最后一轮有界修复:让生产热路径和 Docker 备份/恢复入口不再把数据库凭证放入进程 argv;让紧急恢复卡与唯一系统管理员的事实一致;让 ARM finalize 在被强杀后能够安全恢复明确的孤儿事务,并在歧义状态下保留全部现场、失败告警而非静默停摆。
## Background
- `origin/main` 与当前生产活动构建均为 `a0006f0`;`2526052` 已推送,生产已有 62 项成功 migration,手机号 nullable migration 不需要重复部署。
- 当前待推送提交只有 `de35ab8`、`95b51e6`、`f1e8cf1`;本任务完成前不单独推送,避免本地与远端继续形成多个收尾批次。
- `scripts/run-automatic-backup.sh:344` 使用 `env KEY=value` 调用成对备份,完整 `DATABASE_URL` 和数据库口令会进入 `env` 进程 argv。
- `scripts/backup-db.sh:74`、`scripts/restore-db.sh:101`、`scripts/restore-db.sh:113` 使用 `docker exec -e PGPASSWORD=value`,其中 nightly 自动备份会经过 `backup-db.sh`,因此只修 wrapper 不能关闭热路径暴露。
- 生产 Docker `20.10.24` 已用无敏感合成变量只读实测:`VAR=value docker exec -e VAR ...` 能把客户端环境值继承给容器命令;测试只记录 `inherited=yes`,未读取或输出数据库凭证。
- `deploy/trialrun/runbook.md` 的紧急恢复卡仍写“复制到公司知识库”和“第二名内部人员”,与已经批准的唯一系统管理员口径矛盾。
- receiver 已有 `flock` 和正常错误回滚,但 `SIGKILL` 无法执行 shell 清理;强杀窗口可能留下 `.daily-replacement-*`、`.new.*` 或 `.rollback.*`,下一次 finalize 当前没有启动恢复逻辑。
## Scope
- 修复 wrapper 与三处 Docker DB 工具调用的凭证 argv 暴露,并增加合成回归断言。
- 增加 receiver 锁内日归档/周检查点孤儿事务恢复及 fail-closed 测试矩阵。
- 同步单管理员紧急恢复卡和部署规约;完成本地验证、提交与后续受控部署后归档运维主线。
## Requirements
- 四处已确认的凭证调用必须改为环境继承,敏感值不得出现在调用命令 argv:wrapper 在隔离子 shell 中导出所需变量后调用 `capture_command`;Docker 入口使用 `PGPASSWORD="$POSTGRES_PASSWORD" docker exec -e PGPASSWORD ...`,不得使用 `env DATABASE_URL=...` 或 `-e PGPASSWORD=...`。
- 契约测试中的 `env KEY=value` 是合成夹具,不含真实凭证,不做无意义的批量改写;新增断言必须使用合成标记证明敏感值不出现在 mock 捕获的 argv。
- 紧急恢复卡标题、负责人和季度动作必须统一为单管理员口径:恢复卡保存在现有 runbook,由唯一系统管理员维护并按季度在 ARM 隔离目标执行影子恢复;不虚构知识库副本或第二人门禁。
- finalize 必须在取得现有独占锁之后、处理本次 partial 之前,扫描严格命名的日归档和周检查点孤儿事务;正式点和 rollback 只有在普通目录、非符号链接、位于受控根目录且通过 manifest/文件集/大小/SHA-256 自校验后才可作为权威来源。`.new` 永远不是权威来源,只能在严格路径/类型校验且另一个有效权威来源已经确定后清理。
- `.daily-replacement-*` 的判定必须遵守 `design.md` 的日归档状态表:孤儿与有效当日归档同时存在时删除孤儿;孤儿存在但当日归档缺失时从孤儿 manifest 还原原归档名;当日归档校验失败、存在多个候选或任何身份歧义时 fail closed,两个对象均保留。
- 周检查点 `.new.*`/`.rollback.*` 必须遵守 `design.md` 的对称状态表:rollback 在正式周检查点缺失时优先恢复;正式周检查点有效时临时对象才可清理;只有 staging 且正式点缺失时,必须先找到同 ISO 周唯一、有效的日归档来源再重建;歧义或校验失败一律保留现场并失败。
- 自动清理必须逐目标执行严格路径、类型和命名校验;不得用宽泛 glob 删除,不得触碰正式日归档、正式周检查点、手工批次或最后一个有效恢复点。
- orphan 歧义导致 receiver 非零时,源端必须继续在 `offhost_finalize` 阶段失败、保留源批次和 `0600` 诊断并走独立群机器人告警;ARM dead-man 继续在超龄窗口独立告警。本任务复用已真实验证的链路,不再人为制造第二次真实断链。
- 恢复卡复制到 ARM 和离线副本属于可选纯运维缓解项,不是代码验收阻断;任何 ARM 写入、推送、生产部署或真实备份执行仍需另行批准。
- 本任务完成后硬收口运维加固,不继续新增备份平台、告警能力、保留层级或恢复自动化,后续返回产品 backlog。
## Acceptance Criteria
- [x] 合成 argv 测试证明 wrapper、nightly Docker 备份、Docker 影子确认和 Docker 影子恢复四条路径的命令参数均不包含合成密码或完整连接串,同时容器命令仍收到 `PGPASSWORD`。
- [x] `run-automatic-backup.sh` 不再出现生产路径 `capture_command ... env DATABASE_URL=...`;`backup-db.sh`/`restore-db.sh` 不再出现 `docker exec ... -e PGPASSWORD=value`。
- [x] 恢复卡正文、标题、负责人和季度动作与《管理员手册》单管理员口径一致,且仍保留真实生产恢复的单独批准门禁。
- [x] 日归档测试覆盖“孤儿+有效正式点则清理孤儿”“孤儿+缺正式点则恢复旧点”“孤儿+无效正式点则两个都保留并失败”“重复/非法/链接候选则失败且不删除”。
- [x] 周检查点测试覆盖 `.new`、`.rollback`、正式点存在/缺失、可唯一重建和歧义失败;任何成功恢复后只保留一个正式周检查点,任何歧义失败后现场对象不减少。
- [x] orphan fail closed 通过本地合成链路证明 finalize 非零、源端不写成功状态、不执行源端保留清理且诊断为 `0600`;不重复执行真实企微断链验收。
- [x] 现有同日取代、失败回滚、7 日 + 4 周保留、dead-man、备份/恢复往返、环境合同和敏感扫描均无回退。
- [x] task、Trellis governance、文档注册/流程合同和 `git diff --check` 通过;只有经确认的任务文件进入提交。
## Out of Scope
- 不新增数据库 migration,不重复部署 `2526052`,不修改生产业务数据。
- 不重测已经通过的真实企微失败、重复降噪和恢复通知链路。
- 不自动执行恢复,不删除 ARM 正式归档或手工批次,不改变 7 日 + 4 周保留策略。
- 不改写仅含合成值的测试夹具 `env` 调用,不做全仓无关安全重构。
- 不在本任务直接推送、部署、更新 ARM 脚本或复制恢复卡;这些动作分别保持外部门禁。
- 不继续新增运维能力;本任务之后不因本轮审查再派生新的运维加固 task。
## Test Plan
- Shell 语法检查和 `bash scripts/qa/run-automatic-backup-contract.sh`,扩展 mock argv/环境继承与 orphan 恢复矩阵。
- `bash scripts/qa/run-backup-restore-drill.sh`,覆盖 Docker 备份与影子恢复入口无回退。
- `node deploy/trialrun/validate-env-contract.js`。
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/07-27-offhost-backup-final-fixes`。
- Trellis governance、敏感扫描、文档注册、流程合同和 `git diff --check`。
@@ -0,0 +1,26 @@
{
"id": "offhost-backup-final-fixes",
"name": "offhost-backup-final-fixes",
"title": "异机备份最终收尾修复",
"description": "修复自动异机备份凭证进入进程 argv、单管理员恢复卡漂移和 finalize 强杀后孤儿事务恢复,完成后硬收口运维主线。",
"status": "completed",
"dev_type": null,
"scope": "scripts/run-automatic-backup.sh; scripts/backup-db.sh; scripts/restore-db.sh; scripts/receive-offhost-backup.sh; automatic-backup contracts; trialrun runbook/spec",
"package": null,
"priority": "P0",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-27",
"completedAt": "2026-07-27",
"branch": null,
"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,37 @@
# 自动备份运行身份失败修复设计
## 1. Root Cause
自动备份安装时把附件宿主目录冻结进 systemd unit。后续 server 容器重建使用了另一实际宿主目录,但 unit 未同步刷新。runner 在 `runtime_identity` 阶段比较 Docker 实际 mount source 与 unit 的 `STORAGE_HOST_DIR`,发现不一致后在 DB dump、附件归档和传输前失败关闭。
严格检查本身正确:两个目录均存在、非符号链接、realpath 和 inode 不同,因此不能通过路径规范化或放宽校验修复。
## 2. Safe Refresh
从 daily unit 读取现有环境合同,从唯一运行 server 容器解析实际附件 mount source,仅在内存中替换 `STORAGE_HOST_DIR`。验证所有路径、权限、build 对应关系后,调用仓库安装器 `enable` 原子重写三个 service/timer 文件并 daemon-reload;写入前保存三个 services/timers 的 root-only 临时副本用于失败回滚,成功验证后删除副本。
不在命令输出、task 或文档中记录实际路径、容器 ID、连接串或 webhook。
## 3. Validation Sequence
```text
local 73-check contract
-> production read-only recheck
-> snapshot six unit files
-> installer enable with actual storage mount
-> six-dimension runtime identity check
-> manual daily service start
-> verify new success state + ARM finalize + recovery alert state
-> verify timers and unrelated monitors
-> remove temporary unit snapshot
```
## 4. Rollback
- 安装器或 unit 验证失败:恢复六个 unit 快照、daemon-reload、恢复原 timer 状态;不运行备份。
- unit 正确但补跑备份后续阶段失败:不恢复旧 `STORAGE_HOST_DIR`,因为旧值已证实错误;保留新配置、失败摘要和本地诊断,按具体 phase 另行修复。
- 新批次只有在 ARM finalize 后才更新 success state 和触发本地保留策略,失败不会删除现有最后成功恢复点。
## 5. Prevention
把“部署后 active mount 与自动备份 unit 合同一致”写入部署/试运行规约和 checklist。涉及 data path 的部署不能只验证应用 `/ready`;若自动备份 units 已安装,必须在交付结束前核对或刷新冻结的 project/build/PGDATA/storage 身份。
@@ -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,37 @@
# 自动备份运行身份失败实施清单
## 1. Pre-fix
- [x] 确认 daily service 02:30 非零退出且告警类别为 `phase_runtime_identity_failed`。
- [x] 按 runner 的 Compose 选择逻辑确认唯一根因为附件宿主挂载不匹配。
- [x] 确认不是 symlink/realpath/inode 等价,其他五项 runtime identity 通过。
- [x] 确认最近成功恢复点约 11 小时,尚未超过 watchdog/dead-man 窗口。
- [x] 确认 07:10 `phase_stale_check_failed` 由 watchdog 复用同一 runtime identity 检查触发,不是独立的归档损坏结论。
- [x] 运行本地 `bash scripts/qa/run-automatic-backup-contract.sh`(73 项通过)。
## 2. Unit Refresh
- [x] 重新只读解析当前实际附件 mount source并验证安全边界。
- [x] 保存六个自动备份 unit 的 root-only 临时快照。
- [x] 沿用现有 unit 合同,仅替换实际附件宿主目录,调用安装器 `enable`。
- [x] 验证 daily/watchdog/drill timers enabled/active 和六项 runtime identity 全部匹配。
## 3. Recovery Backup
- [x] 手工启动 daily service;记录稳定 phase/result,不输出原始日志。
- [x] 验证新的 last-success 时间、项目/build 身份和 manifest 哈希格式。
- [x] 通过仅在 ARM finalize 成功后写入的 success state 验证新归档闭环;普通 ARM 账号无 archive 读取权限,未扩大权限取证。
- [x] 验证 alert state 为 `PASS_AUTOMATIC_BACKUP_COMPLETE`。
- [x] 用户确认群内只收到一条 `PASS_AUTOMATIC_BACKUP_COMPLETE / healthy` 恢复通知。
- [x] 验证应用健康 timer、备份 watchdog/drill 和 ARM dead-man/receiver 不受影响。
## 4. Cleanup and Governance
- [x] 删除临时 unit 快照;不删除旧目录、旧批次或失败证据。
- [x] 更新部署规约/checklist 的自动备份 unit 漂移门禁。
- [x] 运行 task validate、governance、sensitive、docs audit 和 `git diff --check`;全部无错误,docs audit 仅保留既有 22 项未登记文档 warning。
## 5. Rollback
- unit 刷新或 identity 复检失败:恢复六个 unit 快照并 daemon-reload,不补跑备份。
- 补跑在 paired/offhost phase 失败:保留已纠正 unit,依据安全 phase 诊断继续处理,不回退旧附件目录。
@@ -0,0 +1,62 @@
# 自动备份运行身份失败
## Goal
恢复每日自动成对备份,使生产 unit 冻结的存储路径重新与当前运行容器一致,补跑并完成一次数据库、附件和 ARM 异机归档闭环,同时保留失败告警的脱敏证据。
## Scope
- 只读确认 02:30 `phase_runtime_identity_failed` 的精确失败维度和最近成功恢复点。
- 使用当前运行 server 容器的实际附件挂载源刷新自动备份三个 systemd services/timers;其他已验证环境值沿用现有 unit,禁止打印路径或秘密。
- 刷新前后验证 active server/db 数量、运行状态、构建 SHA、附件挂载、PGDATA 和数据库身份。
- 手工运行一次 daily backup,要求本地完整批次、ARM finalize、success state 和一条恢复通知全部成功。
- 保持应用健康监控、备份 receiver、ARM dead-man 和业务服务不变。
## Confirmed Facts
- 2026-07-28 02:30 daily service 以非零状态失败,脱敏状态为 `FAIL_AUTOMATIC_BACKUP / phase_runtime_identity_failed`。
- 生产机使用 standalone `docker-compose`;最初诊断命令误用 `docker compose`,已按 runner 相同选择逻辑更正,未把诊断工具错误当作根因。
- 当前 server/db 各唯一且运行中;unit 冻结的构建 SHA、PGDATA、应用/数据库身份均与运行容器匹配。
- 唯一不匹配项是 `STORAGE_HOST_DIR`:unit 配置目录和当前容器实际挂载源都存在,但 realpath/inode 均不同,不是符号链接或路径规范化差异。
- unit 早于当前 server 容器启动,说明部署/重建后容器使用了新附件目录,而自动备份 unit 仍保留旧目录。
- 最近一次成功异机恢复点约 11 小时前,项目和构建身份均匹配;当前未超过 24/26 小时窗口,但本次 02:30 没有产生新恢复点。
- runtime identity 阶段不写 `last-error.log`,因此没有原始诊断残留;脚本按设计在任何备份写入前失败关闭。
- 2026-07-28 07:10 watchdog 又发送 `phase_stale_check_failed`。`check-stale` 在读取 success state 前同样调用 `resolve_active_runtime`,因此该消息由同一个附件挂载漂移触发;它不是第二个独立故障,也不证明最后成功批次本身损坏或过期。
## Requirements
- R1:刷新前再次从运行容器解析唯一实际附件挂载源,要求安全绝对目录、存在、非符号链接,且不得输出路径。
- R2:不得直接手改 unit 单行;使用已提交安装器以完整合同重写 daily/watchdog/drill units,并保持三个 timers enabled/active。
- R3:除 `STORAGE_HOST_DIR` 改为当前实际挂载源外,Compose project、PGDATA、build、远端目标、SSH、webhook 和备份目录等合同必须与现有 unit 一致。
- R4:刷新后先只读复跑六项 runtime identity;任何一项不匹配均不得补跑备份。
- R5:手工补跑必须使用现有 runner,完成数据库 dump、附件归档、manifest、受限传输和 ARM finalize 后才写 success state。
- R6:成功后应把当前告警状态(最新类别为 `phase_stale_check_failed`)切换为 `PASS_AUTOMATIC_BACKUP_COMPLETE` 并发送一次恢复通知;相同健康状态不得重复发送。
- R7:不得删除旧成功批次、旧目录或失败状态;正常保留策略只在新批次已由 ARM finalize 后运行。
- R8:如果 unit 刷新失败,恢复原 unit 文件并 daemon-reload;如果补跑失败,保留刷新后的正确 identity 配置和脱敏失败状态,不回退到旧附件目录。
## Acceptance Criteria
- [x] AC1:根因证据明确为非规范化的附件宿主目录漂移,其他 runtime identity 维度全部匹配。
- [x] AC2:三个自动备份 timer 仍为 enabled/active,刷新后的 daily unit 与当前附件挂载、PGDATA、build、项目和数据库身份全部匹配。
- [x] AC3:手工 daily backup 成功生成并在 ARM finalize 一个新的完整批次,生产 success state 时间更新。
- [x] AC4:群内只收到一次自动备份恢复通知,状态为成功;无秘密、路径或原始日志进入消息/任务证据。
- [x] AC5:应用健康 timer、ARM backup dead-man 和备份 receiver 未受影响。
- [x] AC6:补充防复发合同:部署后若容器宿主挂载变化,必须刷新/核对自动备份 unit,不能等次日告警发现。
## Out of Scope
- 修改数据库、附件内容、OA/Caddy、备份保留数量或 ARM 归档结构。
- 删除旧附件目录或判断其业务处置。
- 重置数据库、恢复数据、改 webhook、SSH key 或真实业务数据。
- 将路径、容器 ID、数据库身份、连接串或原始日志写入文档/聊天。
## Test Plan
- 刷新前后运行脱敏 runtime identity 矩阵。
- 运行本地自动备份 73 项合同,确保安装器/helper/runner 行为无回归。
- 生产补跑后检查 service result、last-success 新鲜度、ARM 新批次 finalize、alert state 和三个 timers。
- 运行 task validate、Trellis governance、敏感内容扫描、文档审计和 `git diff --check`。
## Key Decision
立即修复 unit 的附件路径并补跑备份,不修改严格相等检查。两个目录不是同一 inode,放宽为 realpath 或“目录存在即可”会掩盖真实数据源漂移。
@@ -0,0 +1,26 @@
{
"id": "automatic-backup-runtime-identity-failure",
"name": "automatic-backup-runtime-identity-failure",
"title": "自动备份运行身份失败",
"description": "诊断 02:30 自动备份 phase_runtime_identity_failed 告警,确认影响范围、根因与安全修复路径。",
"status": "completed",
"dev_type": "operations",
"scope": "deployment",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": "2026-07-28",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "02:30 daily 与 07:10 watchdog 均由自动备份 unit 的旧附件宿主目录漂移触发,其他运行身份正常。已用现有安装器刷新 units,三个 timer 与全部运行身份通过;手工补跑、ARM finalize、成功状态、单条群恢复通知和关联监控复核通过,临时快照已删除。73 项合同及治理、敏感、流程、文档和格式门禁无错误,全部验收项闭环。",
"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,63 @@
# CEO 本人报销财务兜底设计
## 1. Boundary
本任务复用现有审批规则和工作流快照机制,不新增角色、数据表、迁移或公开端点。`finance_mgr` 保持兼容;本公司的新默认和生产规则改为 `finance`。
## 2. Cross-layer Contract
```text
审批规则配置
ownerSelfFallbackRole: finance | finance_mgr | null
-> API 白名单校验
-> ApprovalRule 持久化
-> 报销提交加载规则
-> CEO 本人且 manager 无法解析时使用 fallback
-> 移除本人 ceo 节点 + 角色去重
-> 校验 active 非本人角色持有人
-> 冻结 workflow_steps_snapshot
```
创建 API、省略字段时的默认值和前端新建表单默认值统一为 `finance`。旧规则返回 `finance_mgr` 时前端仍可展示、编辑和保存,避免破坏兼容数据。
## 3. Target Rule Matrix
| 金额档 | 普通员工规则 | CEO 本人快照 |
| --- | --- | --- |
| 小额 | `manager -> finance` | `finance` |
| 中额 | `manager -> finance` | `finance` |
| 大额 | `manager -> ceo -> finance` | `finance` |
CEO 本人快照只由现有标准步骤完成:`manager` 缺失时插入 `finance` fallback;随后相同 `finance` 节点去重,申请人自身 `ceo` 节点移除。无需添加按金额或人员姓名硬编码的特殊分支。
## 4. Validation And Failure Behavior
- fallback 不在 `finance|finance_mgr`:`400 VALID_001`。
- CEO 同时持有 fallback:`409 VALID_001 / owner_self_fallback_self_role`。
- 无 active 非本人 fallback 持有人:`409 VALID_001 / owner_self_fallback_role_unavailable`。
- 非 CEO:不触发 owner-self fallback,保留正常规则快照。
- 生产规则数量、金额区间或当前角色结构漂移:回填事务停止,零写入。
## 5. Production Rollout
1. 部署代码与前端,使 runtime 能读写 `finance` fallback;`seed:config` 对既有审批规则只保留不覆盖,避免部署提前改写生产规则。
2. 核对公网构建号和 runtime smoke。
3. 生产只读冻结 3 条规则、active `finance` 持有人、CEO 身份、角色/binding/历史快照计数。
4. 单事务更新 3 条启用规则:中/大额 `finance_mgr` 节点改为 `finance`,全部 fallback 改为 `finance`;逐条写脱敏审计。
5. 只读解析三档 CEO 快照和普通员工快照,不创建真实报销。
## 6. Rollback
- 代码兼容 `finance_mgr`,生产规则可在精确冻结原值后原子恢复。
- 回滚规则只影响回滚后新提交单据;既有 `workflow_steps_snapshot` 保持不变。
- 任一生产检查失败时先回滚规则,不改员工角色或历史单据。
## 6.1 Seed Safety
- 空库首次初始化时创建三条新默认规则。
- 只要审批规则表已有任意记录,`seed:config` 保留全部既有规则,不删除、不覆盖、不补写;生产规则只允许走获批的独立原子事务。
- 部署日志只记录创建/保留计数,不输出规则 ID、人员或业务内容。
## 7. Trade-off
不采用“给现有财务追加 `finance_mgr`”方案,因为该角色同时包含主数据、预算、集成、AI、资产和薪酬等更宽权限,超出“审批 CEO 本人报销”的最小职责。允许 `finance` 作为兜底能复用已有 `expense.approve`,权限面更小。
@@ -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 @@
# CEO 本人报销财务兜底实施计划
## 1. Implementation
- [x] 扩展后端 owner-self fallback 白名单为 `finance|finance_mgr`,创建默认值改为 `finance`。
- [x] 将三档种子默认规则统一为财务 `finance` 收口,保留大额 `ceo` 节点。
- [x] 更新前端 API 类型、创建/编辑选项、默认值和推荐文案;兼容展示 `finance_mgr`。
- [x] 更新工作流、规则 API、报销提交回归测试,覆盖 CEO 三档唯一 `finance`、普通员工链、自审、无持有人和 legacy `finance_mgr`。
- [x] 修正 `seed:config` 对既有审批规则的删除重建副作用;仅空库创建默认规则,生产既有规则保留到独立原子回填阶段。
- [x] 新增默认 dry-run、双确认 apply 的生产规则回填脚本;事务内锁定并校验三档规则、CEO/财务/admin/binding 基线,更新三条规则、写三条脱敏审计并复核受保护计数。
- [x] 生产 dry-run 发现原生 `audit_logs` 插入未携带 Prisma 客户端侧 UUID;事务已回滚。脚本已改为显式写入审计 UUID,并新增执行级回归覆盖三条审计 SQL 与 dry-run 回滚。
- [x] 更新当前业务事实源,区分目标、代码部署和生产规则回填状态。
## 2. Local Validation
- [x] `cd server && node --check src/utils/workflow.js && node --check src/routes/approvalRules.js && node --check src/scripts/seed-config.js && node --check src/scripts/apply-ceo-finance-fallback.js`
- [x] `cd server && npx vitest run tests/unit/utils/workflow.test.js tests/unit/routes/approvalRules.permissions.test.js tests/unit/routes/reimbursements.expense-standards.test.js tests/unit/scripts/seed-config.defaults.test.js tests/unit/scripts/apply-ceo-finance-fallback.test.js`(58 项通过)
- [x] `cd server && npm run prisma:validate`
- [x] `cd client && npm run typecheck && npm run build`
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`(0 error,保留既有 22 个未注册文档 warning)
- [x] `python3 ./.trellis/scripts/task.py validate 07-28-ceo-finance-fallback`
- [x] `node scripts/qa/check-trellis-governance.mjs && git diff --check`
- [x] `bash scripts/check-minimal.sh`(167 个测试文件、1053 项后端测试、前端 build 与矩阵门禁通过)
- [x] dry-run 缺陷修复后复跑 Prisma validate 与 5 个聚焦测试文件(59 项通过)。
## 3. Review Gate
- [x] 只读 review 权限白名单、自审阻断、普通员工链、历史快照不回写、API/UI 类型一致性和生产事务边界;发现并修复部署 seed 提前覆盖规则问题后复核无剩余阻断。
## 4. Deployment And Production Apply
- [x] 提交并推送已验证代码。
- [x] 修复部署脚本已知的数据目录保护缺陷:同步根目录与数据目录任一方向重叠时在 SSH 前阻断,自定义同步树内 `.env` 动态保护;隔离备份/恢复合同通过。
- [x] 部署后核对 `/ready` 构建号、migration 状态、runtime smoke、external deps smoke 和前端 bundle。
- [x] 生产只读冻结规则结构、active `finance`、CEO、角色、binding、历史快照和审计基线。
- [x] 原子更新恰好 3 条启用规则并逐条写脱敏审计;任一预条件漂移则零写入停止。
- [x] 只读解析 CEO 小/中/大额快照均为唯一 `finance`,普通员工链符合目标;不创建真实报销。
- [x] 复核无角色、主管、binding、历史快照或企微写入变化。
部署执行备注:`3594872` 首次部署通过,并将附件从同步树内迁至独立数据目录、保留校验一致的回退副本;首次生产 dry-run 因审计表 UUID 缺失失败并完整回滚。修复提交 `a74cba1` 随后部署,`/ready`、migration、runtime/external-deps smoke、92 个公网资源扫描通过;4 个自动运维 timer 已刷新运行身份。第二次 dry-run 为 `DRY_RUN_PASS`,apply 原子写入 3 条规则与 3 条脱敏审计,受保护计数稳定;只读解析 CEO 三档均为唯一 `finance`、普通员工链符合目标,0 条真实报销、0 次企微写入。
## 5. Stop Conditions
- 生产 active `finance` 不是唯一非 CEO 持有人,或目标人员 inactive/unbound。
- 启用规则不是恰好 3 条,金额区间或角色结构与冻结基线不符。
- 部署路径仍可能删除 `.env`、附件或数据库宿主目录。
- 任一测试出现自审、管理员代批、历史快照重写或普通员工链异常。
- 真实 CEO 报销验证未取得单独批准。
@@ -0,0 +1,69 @@
# CEO 本人报销财务兜底修正
## Goal
让 `majianyong(ceo)` 本人提交任一金额档报销时,在禁止自审的前提下只进入 active 非本人 `finance` 审批;`wangshunli` 的系统管理员身份不进入业务审批链,也不通过给现有财务附加 `finance_mgr` 扩权来绕过问题。
## Scope
- 报销审批工作流的负责人本人 fallback 白名单、快照解析和失败关闭行为。
- 审批规则 API、前端配置页与三档默认种子规则的 `finance` 默认值和 `finance_mgr` 兼容。
- 部署同步根目录与 PostgreSQL、附件和环境文件的安全隔离合同。
- 经部署与公网验证后,对生产恰好 3 条启用报销规则进行一次原子回填和脱敏审计。
## Background
- 用户确认 `majianyong` 是公司 CEO、无直属主管;`wangshunli` 仅承担系统开发和系统管理。
- 生产有 3 条启用报销金额规则,均使用 `owner_self_fallback_role=finance_mgr`,中/大额规则还包含 `finance_mgr` 节点。
- 生产有 1 名 active 且已绑定企微的 `finance`,没有 active `finance_mgr`;当前 CEO 本人报销会以 `owner_self_fallback_role_unavailable` 失败关闭。
- 代码、审批规则 API、前端配置页和种子默认值目前只把 `finance_mgr` 作为负责人本人兜底角色。
- 现有工作流已具备 CEO 自审节点移除、重复角色去重、active 非本人持有人检查和历史审批快照冻结能力。
## Requirements
- `finance` 和兼容保留的 `finance_mgr` 均可作为 `ownerSelfFallbackRole`;新建规则默认使用 `finance`。
- 小团队默认金额规则调整为:小额 `manager -> finance`、中额 `manager -> finance`、大额 `manager -> ceo -> finance`,三档 `ownerSelfFallbackRole` 均为 `finance`。
- CEO 本人申请在三档规则下均生成唯一 `finance` 节点:替换无法解析的 `manager`、移除本人 `ceo`、去除重复 `finance`。
- `finance` 兜底必须存在 active 非本人持有人;CEO 若同时持有 `finance` 或无可用持有人,继续失败关闭。
- 普通员工的工作流仍按规则顺序生成,不引入 CEO 本人专属的全局跳步;历史 `workflow_steps_snapshot` 不回写。
- 审批规则 API 和前端配置页同时支持 `finance`/`finance_mgr`,默认与推荐值显示为 `finance`;不开放 `admin`、`system_admin`、`hr` 或其他角色作为负责人本人兜底。
- 部署后以原子事务将生产 3 条启用规则从现状修正为上述三档结构并写脱敏审计;写入前必须重新确认规则数量、金额区间、角色结构、active 财务持有人和 CEO 身份,任一漂移即停止。
- 生产回填不改员工角色、直属主管、既有报销审批快照、密码、企微 binding 或企微通讯录。
- 公网验证只创建一条由 CEO 本人发起的低风险测试报销时,必须再次取得真实业务写入批准;本任务默认只做规则只读解析/smoke,不自动创建真实报销。
## Acceptance Criteria
- [x] 后端工作流单元测试证明 CEO 本人小/中/大额快照均只有一个 `finance` 节点。
- [x] 无 active 非本人 `finance`、CEO 同时持有 `finance`、不支持的兜底角色均失败关闭且不创建半截工作流。
- [x] 普通员工规则快照保持 `manager -> finance` 或 `manager -> ceo -> finance`,无自审放宽。
- [x] 审批规则 API 接受 `finance` 和兼容的 `finance_mgr`,拒绝非白名单角色;新建默认值为 `finance`。
- [x] 前端类型、创建/编辑选项和推荐文案支持 `finance`,默认选择 `finance`,typecheck/build 通过。
- [x] 种子默认三档规则与目标链一致,相关回归测试通过。
- [x] 只读 review 确认权限、自审、状态机、历史快照和生产回填边界无扩大。
- [x] 部署后公网 `/ready` 构建号匹配,runtime smoke 和关联认证/工作流检查通过。
- [x] 生产恰好 3 条启用规则按目标结构回填并留下脱敏审计;active 账号、角色、binding 和历史审批快照计数无意外变化。
- [x] 当前事实源、管理员手册和任务结论不再宣称 `finance_mgr` 是本公司推荐兜底。
## Test Plan
- 后端语法、Prisma validate,以及 workflow、审批规则 API、报销提交和默认种子规则聚焦测试。
- 前端 typecheck/build 与审批规则配置页聚焦 Playwright。
- 部署环境静态合同、隔离备份恢复演练、流程合同、文档审计、Trellis task/governance 和敏感内容扫描。
- 部署后核对 `/ready` 构建号、migration、runtime/external-deps smoke 和公网 bundle。
- 生产回填前后只读冻结规则、角色、binding、历史快照和审计计数;只读解析 CEO 三档与普通员工链,不创建真实报销。
## Out of Scope
- 不删除 `finance_mgr` 角色或改变其现有权限。
- 不给现有财务人员新增 `finance_mgr`、`manager` 或其他角色。
- 不改变普通报销金额阈值,不重构通用工作流引擎,不修改历史审批快照。
- 不让 `admin`/`system_admin` 获得业务审批权,不允许任何申请人自审。
- 不执行真实付款、企微写入或未经再次批准的真实 CEO 报销提交。
## Technical Notes
- 后端白名单与快照:`server/src/utils/workflow.js`。
- 审批规则 API:`server/src/routes/approvalRules.js`。
- 默认规则:`server/src/scripts/seed-config.js`。
- 前端类型和页面:`client/src/api/approvalRules.ts`、`client/src/pages/ApprovalRulesPage.tsx`。
- 聚焦测试:`server/tests/unit/utils/workflow.test.js`、`server/tests/unit/routes/approvalRules.permissions.test.js`、`server/tests/unit/routes/reimbursements.expense-standards.test.js`。
@@ -0,0 +1,26 @@
{
"id": "ceo-finance-fallback",
"name": "ceo-finance-fallback",
"title": "CEO 本人报销财务兜底修正",
"description": "让 CEO 本人报销在禁止自审的前提下由 active 非本人 finance 审批,并以最小权限修正代码、默认规则和生产配置。",
"status": "completed",
"dev_type": null,
"scope": "server, client, deploy/trialrun, workflow tests, current docs, production approval rules",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": "2026-07-28",
"branch": null,
"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 @@
{"_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,22 @@
# Claude生产复核结论只读核验:执行记录
## 1. 只读核验
- [x] 核验 02:30/07:10 稳定类别、当前 runtime identity、timer 状态和恢复点新鲜度。
- [x] 静态确认 `deploy.sh` 不自动刷新 automatic-backup/WARCH3 units。
- [x] 核验 workspace 历史提交、删除、远端可达性、忽略规则和敏感值模式计数。
- [x] 核验 CEO fallback 白名单、默认值、active 非本人持有人、自审阻断和常量复用。
- [x] 核验企微免密自动绑定的服务端、事务冲突、审计、稳定错误码和前端提示。
- [x] 聚合核验生产成功登录、企微登录、binding、session、杨俊主管和报销状态。
## 2. 验证
- [x] 5 个聚焦 Vitest 文件、73 项测试通过。
- [x] 全部生产命令为只读;未安装 timer、未改配置/数据、未发企微、未创建单据。
- [x] task validate、Trellis governance、敏感内容、流程合同、文档注册和 Git 差异检查全部收口;文档审计仅保留既有 22 项 warning。
## 3. 输出边界
- 只记录计数、布尔值、稳定类别、短构建号和相对时间。
- 不记录完整账号、userid、邮箱、手机号、宿主路径、Secret、日志原文或 payload。
- 工程改进只列为后续任务,不在本任务实施。
@@ -0,0 +1,96 @@
# Claude生产复核结论只读核验
## Goal
以仓库历史、当前生产只读状态和两个既有 Trellis task 为证据,逐项核验 Claude 的复核结论,区分“当前事实”“历史事实”“合理建议”和“需要另行批准的改进”,避免因时间点或指标口径不同误判真实登录、备份风险和当前业务阻断。
## Background
- Claude 认为 02:30 自动备份因附件宿主挂载身份不匹配而失败,守卫按设计拒绝错误备份;并建议把备份 unit 刷新并入部署收尾。
- Claude 认为个人 Trellis workspace 曾被提交后删除,但历史仍可取得;内容扫描风险低,不建议改写 Git 历史。
- Claude 认为 CEO `finance` fallback 与企微免登实现正确。
- Claude 认为截至其检查时没有任何真实登录,两个既有任务的真机验收和杨俊真实报销仍未完成,因此下一步应暂停新开发、先让马建勇真机登录,再让杨俊提交。
## Scope
- 自动备份历史失败、当前 runtime identity、恢复点新鲜度和标准部署脚本的 unit 刷新能力。
- 两个 Trellis workspace 历史提交及当前忽略规则。
- CEO `finance` fallback 与企微首次免密绑定的代码、测试和生产只读状态。
- active 账户成功登录统计、企微真机覆盖、杨俊主管链和真实报销状态。
## Requirements
- 只读核验自动备份最近一次 02:30 失败的稳定类别、运行身份差异、最近成功恢复点年龄,以及当前 unit 与活动 Compose/PGDATA/附件/构建身份是否已经一致。
- 静态核验标准部署脚本是否会自动刷新自动备份 units;区分“当前已经人工刷新”与“未来部署仍可能再次漂移”。
- 核验 `0d58ff1`、`0551212` 和 `.trellis/.gitignore`,只对相关历史 blob 做敏感模式扫描,不输出 workspace 原文。
- 核验 R-5 `finance` fallback 的白名单、active 非本人持有人、自审阻断、默认规则与常量单一来源。
- 核验企微首次免密绑定的服务端映射错误码与前端提示,不把前端文案修正误判为完整后端实现来源。
- 以生产只读数据和既有任务证据核验真实企微/OA 登录次数、人员范围、杨俊报销状态及未勾选验收项;明确证据时间,避免把“此前为 0”写成“当前仍为 0”。
- 最终给出按优先级排序的行动建议,但不安装 timer、不修改部署脚本、不改任务勾选、不写生产数据、不创建报销、不发送企微消息。
## Acceptance Criteria
- [x] 每一项 Claude 结论均标记为“确认 / 部分确认 / 已过时 / 不成立”,并附不泄密的证据来源。
- [x] 明确当前自动备份数据风险、最近成功点、unit 当前一致性和 deploy 自动刷新缺口是否同时成立。
- [x] 明确真实登录的统计口径及当前计数,不以 task checkbox 单独代替生产证据。
- [x] 明确杨俊真实报销是否已提交,以及 CEO fallback 是否位于其直接阻断路径。
- [x] 给出一个最小下一步,区分立即人工操作与后续需要新 Trellis task 的工程改进。
- [x] 全程零生产写入、零企微写入、零真实业务单据创建,聊天和任务文件不出现 Secret、完整账号或原始 payload。
## Test Plan
- 用 systemd、Docker inspect 和本地备份状态文件做生产只读身份矩阵;只输出布尔值、计数、短构建号和相对时间。
- 用 Git object/history 检查 workspace 提交与删除,并只报告敏感模式计数。
- 静态检查 workflow、approval rules、seed、企微路由/服务/前端提示并运行聚焦 Vitest。
- 用生产 PostgreSQL 聚合查询成功认证审计、binding、active session、目标员工主管链和报销计数。
- 运行 task validate、Trellis governance、敏感内容、流程合同、文档注册和 `git diff --check`。
## Out of Scope
- 自动修复或重装备份 units。
- 修改 `deploy.sh`、认证、审批或前端代码。
- 代替马建勇、杨俊或其他员工完成真机登录与真实报销。
- 改写 Git 历史清除已确认低风险的 workspace 提交。
## Technical Notes
- 本任务不需要架构设计;因治理脚本将跨部署、认证和审批核验判定为复杂任务,补充 `implement.md` 记录只读执行顺序和门禁。
- 生产查询只输出计数、布尔值、稳定类别、短构建号和相对时间,不输出人员完整账号、完整远端路径或审计原文。
## Review Results(2026-07-28)
### 1. 自动备份
- **确认**:02:30 的稳定类别为 `phase_runtime_identity_failed`,唯一身份漂移是附件宿主挂载;07:10 的 `phase_stale_check_failed` 是同一 runtime identity 预检失败,不是第二个归档损坏。runner 在数据库 dump、附件归档和传输前失败关闭。
- **已过时**:unit 仍绑定旧附件路径的说法只描述故障时点。当前唯一 server/db、Compose project、PGDATA、附件挂载和四个 service 的构建身份均一致;三项自动备份 timer 与 WARCH3 timer 均 enabled/active。
- **部分确认**:最近成功点在 Claude 核验时约 11 小时;本次复核时约 8.1 小时且仍在 24/26 小时窗口内,但恢复点属于上一构建。当前数据新鲜度风险低,构建差异按合同是 finding,不是备份失败。
- **确认**:`deploy/trialrun/deploy.sh` 没有调用自动备份或 WARCH3 安装器。规约已要求部署后比较并按需刷新,但标准脚本没有自动执行,未来部署仍可能再次漂移。
### 2. Trellis workspace Git 历史
- **确认**:`0d58ff1` 新增两份 workspace 文件共 394 行,`0551212` 删除;原提交已被远端分支包含,因此删除不等于从 Git 历史清除。
- **确认**:对原提交 blob 做值形态扫描,凭证赋值、完整企微机器人地址、邮箱、手机号、私钥和银行卡样式均为 0;不建议为低风险内容改写共享历史。
- **确认**:`.trellis/.gitignore` 当前已忽略 `workspace/`。
### 3. CEO `finance` fallback
- **确认**:`finance` 与 `finance_mgr` 共用 `OWNER_SELF_FALLBACK_ROLE_CODES`,路由不再重复定义白名单;新规则默认 fallback 为 `finance`,三档 seed 的财务节点和 fallback 均为 `finance`。
- **确认**:fallback 只接受 active、未归档且非申请人本人的角色持有人;申请人本人持有 fallback 角色、无非本人持有人和不支持角色均失败关闭。
- **确认**:生产三条 active 规则的 fallback 均为 `finance`,active finance 持有人唯一。相关 5 个测试文件共 73 项通过。
- **确认但非杨俊直接阻断**:杨俊的直属主管是 active CEO,`ceo` 具备 `expense.approve`;CEO fallback 解决的是 CEO 本人申请且无主管时的链路。
### 4. 企微首次免密绑定
- **确认**:服务端普通 OAuth 登录先走已有 binding;缺失时按精确 userid 或唯一企业邮箱匹配 active OA 员工,在事务中复核双向冲突、创建 binding、写脱敏审计并创建会话;冲突或 inactive 均失败关闭。
- **确认**:服务端输出 `account_mapping_required`、`account_mapping_conflict`、`account_mapping_inactive`,前端分别展示对应提示。
- **不成立**:`9b12451`“只有前端改动”的说法不成立。该提交同时修改后端路由并新增自动绑定服务及后端测试;“后端能力确实存在”的最终结论成立。
### 5. 真实登录与杨俊报销
- **已过时**:“当前没有任何人登录过”。生产口径为成功认证审计:6 个 active 账户、6 个 active binding,累计 12 次成功登录事件、3 个 active 登录人;其中企微成功 1 人,且该人是管理员,最近一次约 3.3 小时前。当前另有 1 个 active 会话人、2 个 active session。
- **部分确认**:其余人员的企微真机验收仍未完成。生产企微成功登录仅 1 人、非管理员为 0;两个既有 task 的全员/目标员工验收项仍未勾选。
- **确认**:杨俊 active 且 binding 唯一,但企微成功登录为 0;其名下真实报销总数和非草稿数均为 0。生产当前也没有任何报销单。
## Recommended Next Step
立即人工操作只做一件事:请马建勇本人从企微工作台进入 OA。若成功,再请杨俊本人进入并提交真实报销;若失败,只记录页面稳定提示,不连续试错。后续工程改进另建 Trellis task,把部署后的 unit 身份比较与按需刷新收敛进标准部署收尾;本只读任务不实施该改动。
@@ -0,0 +1,34 @@
{
"id": "claude-production-review-verification",
"name": "claude-production-review-verification",
"title": "Claude生产复核结论只读核验",
"description": "只读核验Claude对自动备份、Trellis历史、CEO财务兜底、企微免登和真实登录/报销状态的复核结论。",
"status": "completed",
"dev_type": null,
"scope": "只读核验自动备份、Git历史、审批fallback、企微免登和真实登录/报销状态",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-28",
"completedAt": "2026-07-28",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"deploy/trialrun/deploy.sh",
"server/src/utils/workflow.js",
"server/src/routes/approvalRules.js",
"server/src/routes/wecomAuth.js",
"server/src/services/integrations/wecomLoginAutoBinding.js",
".trellis/tasks/07-27-yang-jun-reimbursement-readiness/prd.md",
".trellis/tasks/07-28-all-account-login-readiness/prd.md"
],
"notes": "只读核验完成:Claude关于历史备份失败、Git历史、CEO fallback和企微后端能力的主体判断成立;无人登录已过时,杨俊尚未企微首登或提交报销。",
"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,81 @@
# PC 登录页桌面布局优化设计
## 1. Design Goal
只调整普通 PC 浏览器登录页的响应式布局和视觉层级:桌面端通过功能双栏突出企业微信扫码主入口,同时保留完整的密码与邮箱验证码回退;所有认证请求、跳转和错误合同保持不变。
## 2. Current Structure
```text
AuthLayout
max-width: 460px(所有认证页共享)
└─ LoginPage
└─ AuthPanel
├─ 品牌与标题
├─ 登录方式说明
├─ 密码 / 邮箱验证码切换与表单
└─ 企业微信扫码入口
```
问题根因是共享 `AuthLayout` 在所有视口统一限制为 460px,`LoginPage` 内部也只有单列内容流。直接扩大共享宽度会连带影响找回密码和修改密码;直接拉伸表单则会造成过长输入框。
## 3. Target Structure
```text
普通浏览器,桌面 >= 1024px,PC 扫码可用
AuthLayout(login variant, wider container)
└─ AuthPanel
├─ 共享品牌、标题、全局状态
└─ desktop grid
├─ 左:企业微信扫码主入口
└─ 右:密码 / 邮箱验证码切换与表单
普通浏览器,PC 扫码不可用
└─ 宽容器内的受控单列表单,不渲染空白左栏
窄屏 / 企业微信 WebView
└─ 保持现有单列顺序与一键进入/密码回退行为
```
## 4. Component Boundaries
### 4.1 `AuthLayout`
- 增加显式页面级布局 variant,由路由仅为 `/login` 选择宽版桌面容器。
- 默认 variant 继续保持 460px,因此 `/forgot-password` 和 `/change-password` 不受影响。
- PWA 更新提示与页面内容共享同一受控容器,不改变更新行为。
### 4.2 `LoginPage`
- 复用现有查询、状态、事件处理函数和 `data-testid`。
- 将“企业入口”和“备用登录”整理为可响应式排序的语义分区;桌面扫码可用时启用双栏。
- 企业微信扫码按钮在桌面双栏中使用主入口视觉;窄屏沿用现有顺序与按钮行为。
- 顶部绑定/登录结果提示属于全局状态,跨双栏显示,避免只附着在某一种登录方式上。
### 4.3 Shared styles
- 优先使用现有 Tailwind 断点、主题 token、`AuthPanel`、`Button`、`Input` 和状态提示。
- 仅在重复或难以表达的布局规则出现时增加少量 `auth-*` CSS 类,不新增 token 文件或依赖。
## 5. Responsive Contract
| 场景 | 布局 |
| --- | --- |
| 375px | 单列,无横向滚动;保持现有普通浏览器内容顺序 |
| 768px | 单列或受控过渡宽度,不提前挤成双栏 |
| 1024px | PC 扫码可用时进入功能双栏 |
| 1440px | 双栏居中,外层和列宽均有上限,不把表单拉满 |
| 企业微信 WebView | 单列,一键进入优先;不展示 PC 扫码栏 |
| PC 扫码关闭/配置缺失 | 单列回退,不显示空栏或失效按钮 |
## 6. Compatibility and Risk Controls
- 不修改 `fetchWecomConfig`、`buildWecomLoginUrl`、redirect、设备指纹或任何服务端代码。
- 不改现有输入、按钮和提示的测试标识;布局测试只增加容器级标识。
- 主要风险是 JSX 重排导致条件渲染遗漏、移动端顺序回退或共享认证页被扩宽。通过场景化 Playwright、边界尺寸断言和截图复核控制。
- 不引入新的持久化状态、迁移或生产配置,因此应用回滚只需回滚本任务前端提交。
## 7. Rollout Boundary
- 本地验证不等于线上可测。
- 若后续需要部署,必须另行获得明确批准,并完成公网登录页、扫码跳转、密码/邮箱入口和构建标识检查后才能通知用户测试。
@@ -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,52 @@
# PC 登录页桌面布局优化实施计划
## 1. Preconditions
- [x] 用户确认创建独立 Trellis task。
- [x] 用户确认桌面端采用“左侧企业微信主入口、右侧备用登录表单”的功能双栏。
- [x] 用户已批准本 PRD、设计和实施计划,可以运行 `task.py start` 并修改产品代码。
## 2. Implementation
- [x] 为 `AuthLayout` 增加显式登录页宽版 variant,并在路由中只对 `/login` 启用;默认认证页宽度保持不变。
- [x] 重组 `LoginPage` 的展示层,把全局提示、企业微信主入口和备用登录表单整理为响应式分区。
- [x] 在普通 PC 浏览器且扫码可用时启用桌面功能双栏;扫码不可用、窄屏和企微 WebView 使用受控单栏。
- [x] 保留现有认证查询、跳转、回跳、设备指纹、表单状态、错误处理和全部既有控件测试标识。
- [x] 未增加依赖、平行设计系统或新的 CSS token,仅复用现有 Tailwind、主题 token 和共享组件。
## 3. Focused Validation
- [x] 扩充登录页 Playwright 覆盖:1440px 双栏、1024px 双栏、768px 单栏、375px 单栏、扫码关闭单栏、企业微信 WebView 单栏。
- [x] 断言桌面双栏的实际边界、相对位置和合理表单宽度,而不只断言元素存在。
- [x] 回归企业微信扫码跳转的 `entrySource`、`returnTo` 和设备指纹,以及密码/邮箱切换、找回密码和 PWA 输入保持。
- [x] 生成代表性桌面与移动截图并人工复核信息层级、留白、对比度、焦点和无横向滚动。
建议命令:
```bash
cd client && npx playwright test tests/e2e/wecom-entry.spec.ts tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list
cd client && npm run typecheck
cd client && npm run build
```
## 4. Project Gates
- [x] `python3 ./.trellis/scripts/task.py validate 07-30-pc-login-desktop-layout`
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `git diff --check`
## 5. Review and Rollback
- [x] 对最终 diff 做认证零变更复核,确认没有服务端、接口、配置、回调或生产文件变化。
- [x] 已通过 1024px 首轮测试发现备用列过宽并收敛到约 404px;未通过改认证逻辑绕过视觉问题。
- [x] 已确认生产部署不属于当前规划批准;如需部署,另行申请并按项目公网检查边界执行。
## 6. Local Evidence (2026-07-31)
- `E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/wecom-entry.spec.ts tests/e2e/login-portal-ux.spec.ts --workers=1 --reporter=list`:19 passed。
- `npm run build`:通过;Vite 2383 modules transformed,PWA 102 entries。
- 代表性截图保存在本地忽略目录 `output/playwright/pc-login-{375,768,1024,1440}.png`,人工复核通过。
- 项目门禁全部通过;`audit-docs` 只有既有 22 个未注册 docs 文件 warning。
- 本地开发完成,尚未部署,不能告知用户线上可测。
@@ -0,0 +1,84 @@
# PC 登录页桌面布局优化
## Goal
让普通 PC 浏览器中的 OA 登录页呈现明确的桌面端信息层级和合理空间利用,不再像放大后的手机卡片;同时保持已经完成真实验收的企业微信扫码登录、账号密码、邮箱验证码、找回密码、回跳和错误提示行为不变。
## Background
- 当前普通浏览器登录页由共享 `AuthLayout` 约束为最大宽度 460px,桌面视口仍使用单列卡片,因此用户明确反馈“过窄、像手机页面”。
- 登录页同时承载企业微信扫码入口、账号密码和邮箱验证码;现有企业微信扫码链路已经完成生产真实验收。
- `AuthLayout` 也被找回密码和修改密码页面复用,不能为了登录页变宽而无意改变全部认证页面。
- 现有移动端和企业微信 WebView 登录路径已有独立行为,本任务只处理普通 PC 浏览器的桌面布局表达。
## Scope
- 普通 PC 浏览器 `/login` 的页面级宽版容器和响应式功能双栏。
- 企业微信扫码主入口、密码/邮箱验证码备用登录区的视觉层级与布局重组。
- 375px、768px、1024px、1440px 代表性视口和企业微信 WebView 的布局回归。
- 登录页聚焦 Playwright、截图复核和前端/项目治理门禁。
## Requirements
### R1 桌面布局
- 普通 PC 浏览器在桌面断点采用“左侧企业微信主入口、右侧备用登录表单”的功能双栏,使用更符合桌面操作的宽度、分区和留白,不能只把现有 460px 单列卡片机械拉宽。
- 登录方式的优先级必须清晰:企业微信扫码为日常首选,账号密码和邮箱验证码为可见、可用的回退方式。
- 表单控件保持适合阅读和输入的宽度,不因外层加宽而出现过长输入框。
- PC 扫码入口不可用时,页面应自然退回受控的单栏表单表达,不保留空白主入口栏。
### R2 响应式与共享页面边界
- 手机和窄屏继续使用现有单列登录体验,不出现横向滚动、截断或双栏挤压。
- 企业微信 WebView 继续优先展示一键进入及必要的密码回退,不套用普通 PC 扫码布局。
- 找回密码、修改密码等共享 `AuthLayout` 页面默认保持现状;如需调整共享容器,只能通过页面级布局能力显式启用。
### R3 认证行为零变更
- 不修改企业微信配置接口、扫码跳转、OAuth/state/cookie、回调、账号映射、会话、审计和生产开关。
- 不修改账号密码、邮箱验证码、找回密码、PWA 更新提示、安全 redirect 或设备指纹行为。
- 保留现有自动化依赖的登录控件语义和 `data-testid`;仅在视觉验收确有需要时增加布局测试标识。
### R4 视觉与可访问性
- 复用现有品牌、主题 token、`AuthPanel`、按钮、输入框和状态提示,不新建平行设计系统,不引入额外 UI 依赖。
- 保持可见标签、键盘焦点、足够对比度、状态提示语义和 `prefers-reduced-motion` 边界。
- 桌面布局至少覆盖 1024px 和 1440px;响应式回归至少覆盖 375px 和 768px。
## Acceptance Criteria
- [x] 1024px 与 1440px 普通浏览器中,登录页形成明确的桌面端布局,不再呈现居中的 460px 手机式窄卡片。
- [x] 企业微信扫码入口在桌面首屏具有清晰主入口地位,账号密码和邮箱验证码仍可直接找到并正常切换。
- [x] 375px 与 768px 视口保持单列、无横向滚动,现有输入、提示和按钮完整可用。
- [x] 企业微信 WebView 的一键进入、失败提示和账号密码回退布局及行为不回退。
- [x] 找回密码和修改密码页面未被登录页的桌面专属布局意外扩宽或改版。
- [x] 现有登录方式、returnTo、设备指纹、PWA 输入保持和扫码入口自动化回归通过。
- [x] 前端 typecheck、build、聚焦 Playwright、代表性视口截图、Trellis 治理检查和 `git diff --check` 通过。
- [x] 未经另行批准未修改生产配置或部署;当前仅能说明本地验证通过,尚未线上可测。
## Local Verification Evidence (2026-07-31)
- 1024px 与 1440px 截图确认桌面功能双栏、企业微信主入口和约 404px 备用表单列;375px 与 768px 截图确认单列、表单先于企业入口且无横向滚动。
- 聚焦 Playwright 共 19 项通过,覆盖扫码跳转参数、企微 WebView、密码/邮箱验证码、找回密码、PWA 输入保持、扫码关闭回退及四档视口布局。
- `npm run build` 通过,Vite 转换 2383 个模块,PWA 生成 102 个预缓存条目。
- task validate、流程合同、文档注册审计、Trellis 治理和 `git diff --check` 通过;文档审计保留 22 个未注册 docs 文件的既有 warning,本任务未新增文档。
- 未修改服务端、认证接口、企微配置、生产配置或业务数据,未执行部署和公网检查。
## Out of Scope
- 认证协议、服务端接口、企业微信后台配置、生产功能开关和账号映射规则。
- 在 OA 页面内嵌企业微信二维码、引入企微 SDK/script/iframe 或重做扫码流程。
- 找回密码、验证码失效提示、首次改密和全员账号回退体验收口。
- 移动端或企业微信 WebView 的视觉重构。
- 新增营销式欢迎页、功能宣传、轮播、客户背书或无实际作用的装饰内容。
## Key Decision
- 用户已确认:普通 PC 浏览器在桌面断点采用功能双栏,左侧突出企业微信扫码入口,右侧承载账号密码与邮箱验证码回退;窄屏和企业微信 WebView 不采用该双栏。
## Test Plan
- Playwright 覆盖 1024px/1440px 双栏,375px/768px 单栏,扫码关闭单栏和企业微信 WebView 单栏,并断言实际布局边界与无横向滚动。
- 回归企业微信扫码入口的 `entrySource`、`returnTo` 和设备指纹,密码/邮箱验证码切换、找回密码入口及 PWA 输入保持。
- 生成桌面与移动代表性截图,人工复核信息层级、表单宽度、间距、对比度和焦点状态。
- 执行 `cd client && npm run typecheck && npm run build`、聚焦 Playwright、Trellis task validate、流程/文档/治理检查及 `git diff --check`。
@@ -0,0 +1,33 @@
{
"id": "pc-login-desktop-layout",
"name": "pc-login-desktop-layout",
"title": "PC 登录页桌面布局优化",
"description": "优化普通 PC 浏览器登录页的桌面布局,保持既有认证与回调链路不变。",
"status": "completed",
"dev_type": null,
"scope": "client 登录页桌面响应式布局、共享认证布局的页面级 variant、聚焦前端回归测试",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-30",
"completedAt": "2026-07-31",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [
"client/src/layouts/AuthLayout.tsx",
"client/src/router/index.tsx",
"client/src/pages/LoginPage.tsx",
"client/src/index.css",
"client/tests/e2e/wecom-entry.spec.ts",
"client/tests/e2e/login-portal-ux.spec.ts"
],
"notes": "本地实现与验证完成:PC 登录页桌面双栏、窄屏/企微单栏、扫码关闭回退均通过;未部署,尚未线上可测。",
"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,143 @@
# PC 企业微信扫码登录设计
## 1. Design Goal
在普通 PC 浏览器提供企业微信托管 Web 登录入口,同时复用现有身份映射、会话签发和回调错误处理。首版不向 OA 登录页注入企微脚本,不改变工作台免登、绑定或 onboard 流程。
官方依据:
- 企业微信《开始开发》:新版 Web 登录支持移动端企微扫码和企微桌面端快速登录,并要求自建应用开启“企业微信授权登录”。
- <https://developer.work.weixin.qq.com/document/path/98151>
- 企业微信《Web登录组件》:托管页链接为 `https://login.work.weixin.qq.com/wwlogin/sso/login`,自建应用使用 `login_type=CorpApp`、CorpID、AgentID、URL 编码后的回调地址和 state。
- <https://developer.work.weixin.qq.com/document/path/98152>
- 企业微信《获取用户登录身份》:Web 登录 code 继续由 `/cgi-bin/auth/getuserinfo` 消费,code 一次有效且 5 分钟过期。
- <https://developer.work.weixin.qq.com/document/path/98176>
## 2. Current Architecture
```text
企微工作台 WebView
-> /api/auth/wecom/login
-> open.weixin.qq.com/connect/oauth2/authorize
-> /api/auth/wecom/callback
-> auth/getuserinfo
-> userid 映射/自动映射
-> OA session + 审计 + returnTo
```
现有 `mode` 只表达 `login / bind / onboard`。回调会把未知 mode 收敛为 `login`,因此不能用 `mode=qr` 承担入口区分。
## 3. Target Architecture
```text
普通 PC 浏览器
-> 登录页“企业微信扫码登录”
-> /api/auth/wecom/login?entrySource=pc_qr
-> 写入短期浏览器关联 cookie
-> login.work.weixin.qq.com/wwlogin/sso/login
-> /api/auth/wecom/callback?code=...&state=...
-> 校验签名 state + 过期时间 + entrySource + cookie nonce
-> 清除关联 cookie
-> 复用 auth/getuserinfo、userid 映射、session 与安全回跳
企微工作台 WebView
-> 保持现有入口和授权 URL
-> callback 缺少旧版 entrySource 时兼容解释为 wecom_workbench
```
## 4. Contracts
### 4.1 Entry configuration
`GET /api/auth/wecom/config` 在现有返回中增加:
- `pcWebLoginEnabled: boolean`
- `pcLoginUrl: string | null`
新增非敏感生产开关 `WECOM_PC_WEB_LOGIN_ENABLED`,默认关闭。只有现有企微 OAuth 配置完整且该开关打开时返回 PC 登录 URL。该开关允许独立回滚扫码入口,不影响工作台免登。
现有 `loginUrl` 继续只代表工作台入口,避免客户端误把两种企微授权地址混用。
### 4.2 Entry source
- 请求白名单:`wecom_workbench | pc_qr`。
- `/wecom/login` 未传值时默认为 `wecom_workbench`,保持既有调用兼容。
- state 中写入白名单化 `entrySource`。
- 回调兼容部署前已经发出的 state:缺少 `entrySource` 时按 `wecom_workbench` 处理;存在未知值时失败关闭,不静默降级。
- `mode` 不增加新值,绑定与 onboard 不允许使用 `pc_qr`。
### 4.3 Hosted Web login URL
仅服务端构造:
```text
https://login.work.weixin.qq.com/wwlogin/sso/login
?login_type=CorpApp
&appid=<CorpID>
&agentid=<AgentID>
&redirect_uri=<encoded callback>
&state=<encoded signed state>
```
CorpID、AgentID 和回调地址复用现有配置;Secret 不进入 URL 或前端。
### 4.4 Browser correlation and replay boundary
现有 state 是 `base64url(payload).HMAC-SHA256`,具备完整性和过期校验,但 nonce 没有消费状态。PC 扫码增加浏览器关联 cookie:
- HttpOnly、Secure(生产)、SameSite=Lax。
- path 限制为 `/api/auth/wecom`。
- 生命周期不长于 state 的 10 分钟。
- cookie 保存服务端签名的 nonce/入口上下文,或保存与 state nonce 可恒定时间比较的不可伪造值。
- callback 在调用企微 token/code 接口前校验;缺失、篡改或不匹配时失败关闭。
- 无论回调成功还是已进入终止性失败,都清除关联 cookie;正常浏览器第二次回调因 cookie 已清除而在 OA 侧先失败。
- 关联 cookie 是浏览器相关性/CSRF 控制,不是服务端一次性 nonce 存储。恶意客户端若同时保存并重放 URL 与旧 cookie,最终单次消费边界由企微 code 的“一次有效、5 分钟过期”合同保证;OA 不创建第二个会话。
工作台既有入口不强制增加该 cookie,避免扩大本任务兼容风险。若实施测试证明企微 WebView 可无差别支持,仍不顺手改变其合同。
### 4.5 Audit and privacy
成功登录继续使用 `AUTH_WECOM_LOGIN_SUCCESS`,extra 增加:
- `bindingSource`
- `entrySource`
日志和审计不得记录 code、state、nonce、cookie、Secret、完整 userid 或企微原始响应。失败日志仅记录固定错误类别、request id、mode 和 entrySource。
### 4.6 Frontend behavior
- 普通浏览器在“企业入口”区域展示“企业微信扫码登录”按钮。
- 点击按钮沿用当前安全 `returnTo` 与设备指纹拼接工具,跳转服务端 `pcLoginUrl`。
- 企业微信 WebView 不展示该按钮,继续显示“一键进入”。
- PC 入口不可用时不展示失效按钮;密码和邮箱验证码始终不受影响。
- 不加载第三方 SDK、script 或 iframe。
## 5. Error Matrix
| Condition | Behavior |
| --- | --- |
| PC 开关关闭或基础配置缺失 | `pcLoginUrl=null`,登录页保留既有方式 |
| 非法 `entrySource` | 400 或认证失败关闭,不重定向企微 |
| `pc_qr` 搭配 bind/onboard | 参数校验失败,不进入外部授权 |
| state 篡改/过期 | 401,不兑换 code |
| PC 关联 cookie 缺失/篡改/nonce 不匹配 | 401,不兑换 code |
| 正常浏览器重复回调 | cookie 已清除,OA 关联校验失败 |
| 恶意客户端重放 URL 与旧 cookie | 企微拒绝重复 code;OA 不创建第二个会话 |
| returnTo 外域或协议相对 URL | 回落安全默认页 |
| 未映射/冲突/停用 | 复用现有结构化提示并失败关闭 |
| 企微托管页/CDN 不可达 | OA 登录页不受影响,用户返回后可用密码/邮箱验证码 |
## 6. Compatibility and Rollout
- 无数据库迁移。
- 缺少 `entrySource` 的短期旧 state 仍可完成工作台回调。
- 先在企微管理后台人工只读核对授权登录与可信域名,再部署代码。
- 部署后先保持 PC 开关关闭完成既有登录回归;经生产配置批准后开启并做一名已绑定 active 员工的真实扫码验收。
- 回滚优先关闭 `WECOM_PC_WEB_LOGIN_ENABLED`;必要时回滚应用版本。两种方式都不影响已有登录入口。
## 7. Explicit Non-Decisions
- 不引入 `@wecom/jssdk`。
- 不在本任务开启 CSP;托管页方案避免新增登录页第三方脚本,但不把既有 `contentSecurityPolicy: false` 宣称为已治理。
- 不新增 nonce 数据表、Redis 或通用 OAuth 状态服务。
- 不把 PC 扫码用于 bind/onboard。
@@ -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,122 @@
# PC 企业微信扫码登录实施计划
## 1. Preconditions
- [x] 用户在企微后台只读确认自建应用已开启“企业微信授权登录”。
- [x] 用户在企微后台只读确认 OA 公网域名已配置为 Web 授权回调域名;生产配置与托管页契约检查继续使用现有 `/api/auth/wecom/callback`。
- [x] 不在代码、日志、Trellis 或聊天中输出 CorpID、AgentID、Secret、真实 userid、code 或 state。
- [x] 用户批准本 PRD、设计和实施计划后,才运行 `task.py start` 并修改产品代码。
## 2. Backend Implementation
- [x] 在配置层增加默认关闭的 `WECOM_PC_WEB_LOGIN_ENABLED`,同步示例环境、部署检查和运行时 readiness 合同。
- [x] 扩展企微公开配置响应,分别返回工作台 `loginUrl` 与 PC `pcLoginUrl`,避免语义混用。
- [x] 在 `/wecom/login` 增加固定白名单 `entrySource`,拒绝 `pc_qr + bind/onboard`。
- [x] 增加企微托管 Web 登录 URL 构造函数,复用现有 CorpID、AgentID 和回调地址。
- [x] 为 `pc_qr` 写入短期 HttpOnly/SameSite=Lax 浏览器关联 cookie,并把白名单入口与 nonce 写入签名 state。
- [x] callback 在外部兑换前验证 state、entrySource、时效和 cookie nonce,并在终止路径清除 cookie;不把无状态 cookie 误报为服务端一次性 nonce 存储。
- [x] 成功审计增加脱敏 `entrySource`,保持既有 `bindingSource`、映射和会话合同。
## 3. Frontend Implementation
- [x] 扩展企微配置类型并在普通浏览器登录页展示“企业微信扫码登录”按钮。
- [x] 复用既有 returnTo 和设备指纹 URL 工具跳转服务端入口。
- [x] 企微 WebView 继续展示工作台一键进入;PC 开关关闭或配置缺失时保持密码/邮箱验证码入口可用。
- [x] 不安装、不加载 `@wecom/jssdk`,不加入第三方 script/iframe。
## 4. Automated Validation
### Backend focused tests
- [x] 配置 ready/disabled/misconfigured 与 `pcLoginUrl` 合同。
- [x] 托管页 URL 的 host/path、`CorpApp`、CorpID、AgentID、回调和签名 state。
- [x] `entrySource` 默认、白名单和非法组合。
- [x] state 篡改、过期、缺 code、非法 returnTo。
- [x] cookie 缺失、篡改、nonce 不匹配、回调后清除、正常浏览器重复回调,以及企微重复 code 不创建第二会话。
- [x] 成功、未映射、冲突、OA/企微停用以及审计脱敏。
- [x] 工作台、bind、onboard 回归。
建议命令:
```bash
cd server && npm test -- tests/unit/routes/wecomAuth.test.js tests/unit/routes/auth.login.test.js
cd server && npm run prisma:validate
```
### Frontend focused tests
- [x] 普通浏览器显示 PC 企微按钮并构造安全跳转。
- [x] 企微 WebView 不重复显示 PC 入口。
- [x] 配置关闭/失败时既有登录方式仍可用。
- [x] 既有错误提示不再要求用户先用密码绑定。
建议命令:
```bash
cd client && npm run typecheck
cd client && npm run build
```
### Project gates
```bash
node scripts/qa/check-process-contract.mjs --baseline --json
node scripts/qa/audit-docs.mjs --check-registry
node scripts/qa/check-trellis-governance.mjs
python3 ./.trellis/scripts/task.py validate 07-30-pc-wecom-qr-login
git diff --check
```
## 5. Review and Documentation
- [x] 认证改动完成后做只读安全/权限 review,不由 reviewer 修改代码。
- [x] 若实现形成稳定合同,更新 `.trellis/spec/hyb-oa/wecom-boundary.md`。
- [x] 仅在确有操作差异时更新现有管理员手册、部署 checklist/runbook;不新建平行文档。
- [x] commit 使用中文并引用 `07-30-pc-wecom-qr-login`。
## 6. Production Gate
- [x] 企微后台“企业微信授权登录”和回调域名核验已由用户完成。
- [x] 生产部署已单独获批并完成;未同步或覆盖生产 `.env`,PC 扫码开关保持关闭。
- [x] 生产配置开启 PC 扫码功能已单独获批并完成。
- [x] 用户按获批生产验收步骤完成真实 PC 扫码登录。
- [x] 部署后公网 `/ready`、前端构建标识、企微公开配置、密码/邮箱验证码页面入口和工作台入口可用性冒烟通过;本轮未输入凭据或发送验证码。
- [x] 开启 PC 开关后,已映射 active 用户完成真实 PC 扫码登录并进入 OA,无需 OA 密码或首次手工绑定。
- [x] 生产只读审计确认最近成功登录为 `entrySource=pc_qr`,且 extra 仅含 `bindingSource` 与 `entrySource`,无敏感键。
- [x] 实际回滚阶段验证开关关闭后 PC 按钮消失,密码、邮箱验证码和工作台入口继续存在;后续已重新成功开启。
## 7. Interrupt and Rollback
- 真人报销链若出现阻断问题,本任务保持小步提交并可随时中断,优先处理真实业务阻断。
- 生产首选回滚:关闭 `WECOM_PC_WEB_LOGIN_ENABLED` 并重建/重启受控服务。
- 若后端回归异常,回滚本任务应用版本;不得删除既有企微绑定或会话数据。
## 8. Local Evidence (2026-07-30)
- 聚焦后端:`wecomAuth.test.js` 与 `check-wecom-runtime-ready.test.js` 共 37 项通过;补充重放断言后 `wecomAuth.test.js` 26 项通过。
- 完整后端:169 个测试文件、1091 项通过。
- Prisma schema validate 通过。
- 前端 typecheck、生产 build 通过;`wecom-entry.spec.ts` 2 项通过。
- 流程合同、文档审计(仅 22 个既有未注册文档 warning)、Trellis governance、task validate、敏感内容扫描和 `git diff --check` 通过。
- 未执行企微后台写入、生产环境配置、部署或真实扫码;对应 Production Gate 保持未勾选。
## 9. Production Deployment Evidence (2026-07-30)
- `45dc206` 与功能提交 `fb164eb` 已推送;生产发布来源为 `fb164eb6903d`,来源已进入 upstream,工作树干净。
- 部署前 PC 扫码开关为未设置或关闭,且已完成一次自动异机备份;发布使用 `existing` 数据模式和 `SYNC_ENV=0`,未同步、回显或覆盖生产配置。
- Prisma 62 个迁移均为最新;配置种子保留生产 3 条既有审批规则,未重置管理员密码。
- external deps、runtime smoke、公网 `/ready`、12 位构建标识和公网前端资源扫描通过;企微公开配置明确 `pcWebLoginEnabled=false` 且 `pcLoginUrl=null`,直接请求 PC 入口失败关闭。
- 公网真实浏览器只读核验确认密码和邮箱验证码标签存在、工作台入口提示存在、PC 扫码按钮数量为 0;未输入凭据或触发验证码。页面仅有既有 favicon 404,不影响登录入口。
- 自动备份三组 timer 和 WARCH3 timer 已刷新到新构建,4 个 timer 均为 enabled/active;发布后自动备份与新鲜度检查通过。
- 运行时依赖审计显示客户端无高危/严重,服务端无严重但仍有 16 个高危告警;本任务没有改动任何依赖或锁文件,该存量风险另行治理,不在认证任务中盲目执行破坏性升级。
- 本节记录的关闭态部署阶段尚未执行企微后台核验、生产开关开启或真实扫码;后续开启结果见第 10 节。
## 10. Production Enablement Evidence (2026-07-30)
- 用户只读确认企微自建应用已开启授权登录,回调域名已配置为 OA 公网域名;未修改 Secret。
- 生产开关写入和 server 重建已单独获批。首次命令在 Compose 只读状态查询时因未重复显式注入构建标识而失败,自动回滚后已证明配置、server 和公网均恢复关闭态。
- 第二次使用完整构建标识和实测数据挂载重建 server,但主机无 Node 使外层 JSON 验证循环永远不能通过。中间时点只读检查曾观察到短暂开启,但脚本稍后按设计延迟回滚;用户看到旧入口后,公网复核证明开关已恢复为关闭。
- 第三次改用生产主机已有的 Python 校验 `/ready`,脚本完整退出且明确 PASS。其后在逐步延迟的 3 次公网采样中,开关持续为开启、`pcLoginUrl` 持续存在,构建标识未变。
- 稳定开启后 external deps、runtime smoke 和 4 个 timer 状态通过。直接 PC 入口契约验证确认 302 到企微托管 Web 登录页、`login_type=CorpApp`、state 存在,且关联 cookie 为 HttpOnly/SameSite=Lax;未跟随至企微或消费 code。
- 公网真实浏览器确认“企业微信扫码登录”按钮恰好 1 个,密码和邮箱验证码入口继续存在。
- 用户完成真实 PC 扫码并成功进入 OA。生产只读审计确认最近成功登录为 `entrySource=pc_qr`,时间与本次验收一致,extra 仅含两个允许字段,不含 code、state、nonce、Secret、userid 或原始响应键。

某些文件未显示,因为此 diff 中更改的文件太多 显示更多