比较提交

65 次代码提交
作者 SHA1 备注 提交日期
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
共修改 217 个文件,包含 16639 行新增和 1431 行删除
+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
@@ -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
+419 -4
查看文件
@@ -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,328 @@ 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
```
@@ -36,6 +36,62 @@ 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);
```
## Convention: Frontend Design Token Sources
**What**: Runtime visual tokens use the sources that are actually consumed by
@@ -168,6 +224,65 @@ 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 }}>`.
### 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.
- 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: task detail link -> existing reimbursement detail route and overview visible.
- 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 +1764,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 +1863,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 +1875,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 +1892,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 +1917,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.
@@ -15,6 +15,659 @@ 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 Presentation And Item Linking
### 1. Scope / Trigger
- Trigger: changing PC or mobile reimbursement-detail presentation for invoice
originals, supporting attachments or their relationship to expense items.
- Purpose: let finance reconcile each item with its invoice without exposing a
separate technical “open file” button for every material.
### 2. Signatures
- Existing detail payload: `detail.items[]`, `detail.invoices[]` with
`invoice.linkedItemId: string | null`, and `detail.attachments[]`.
- Existing preview actions: `openInvoiceMutation` and
`handleOpenAttachment`; WeCom continues through `previewFile` and PC through
its existing browser-safe path.
- Stable UI selectors: `reimbursement-materials` and
`mobile-reimbursement-materials`; existing invoice/attachment child test IDs
remain available for focused regressions.
### 3. Contracts
- Present invoice originals and supporting attachments under one user-facing
`报销材料` section with separate type labels and counts. Do not merge their
database types, API fields, authorization, audit or lifecycle rules.
- Within each expense item, render only invoices whose
`linkedItemId === item.id`. The linked invoice summary is itself a semantic
preview button and shows an invoice identifier, seller and amount.
- Keep every invoice, including unlinked or stale-linked invoices, in the
material section. Never infer a link from amount, order or list position.
- The material information row is the preview target; do not append a repeated
visible `查看原件` button to each file. Draft-only add/remove/upload/delete
actions remain separate buttons so preview cannot mutate data.
- Do not introduce a file-browser route, carousel, bulk download or aggregate
backend endpoint for the small-team detail page unless later real use proves
that the inline material list is insufficient.
- Clickable rows have a descriptive `aria-label`, visible focus, disabled state
during open, a chevron affordance and a minimum 44px mobile touch target.
### 4. Validation & Error Matrix
- Matching `linkedItemId` -> show the invoice inside that item and in the
material section.
- `linkedItemId=null` or target item absent -> show only in the material
section; do not attach it to the first or amount-matching item.
- No invoices and no attachments -> one `暂无报销材料` empty state.
- Preview pending -> disable repeated opens; existing preview errors and
download-reason behavior remain authoritative.
- Read-only approver -> preview rows visible, mutation actions absent.
### 5. Good/Base/Bad Cases
- Good: a hotel item shows its linked hotel invoice inline, while an unlinked
payment screenshot remains under `报销材料` and both originals are previewable.
- Base: one item, one linked invoice and no attachment renders one inline
invoice summary plus one typed material row.
- Bad: flatten invoices away from items, guess links by equal amount, merge all
file records into one backend type, or add a `查看原件` button beside every
row.
### 6. Tests Required
- Focused PC and WeCom Playwright fixtures contain at least one linked and one
unlinked invoice plus one supporting attachment.
- Assert the linked invoice appears only in the matching item; the unlinked
invoice appears in materials but not in any item.
- Assert material counts, absence of exact visible `查看原件` buttons, PC open
URL behavior, WeCom `previewFile` name/size/URL, 44px touch target and mobile
portrait/landscape overflow.
- Preserve editable add/remove/upload/delete regression coverage, then run
frontend typecheck/build and Trellis governance gates.
### 7. Wrong vs Correct
#### Wrong
```tsx
detail.invoices.map((invoice) => <Button>查看原件</Button>);
```
#### Correct
```tsx
detail.invoices
.filter((invoice) => invoice.linkedItemId === item.id)
.map((invoice) => (
<button aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}>
{formatInvoiceDisplayTitle(invoice)}
</button>
));
```
## 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
+501 -5
查看文件
@@ -41,6 +41,183 @@ 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.
- 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.
### 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.
### 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.
- 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.
- 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
```
## 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 +345,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 +368,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 +411,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,213 @@
# 杨俊报销就绪:技术设计
## 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 对齐
- 交互采用“展示统一、语义保留”:用户界面把发票原件和补充附件统一归入“报销材料”,但 API、数据库类型、授权、审计和生命周期保持不变;付款后材料不在本轮提前接入。
- 财务核对以费用明细为主。两端页面按 `invoice.linkedItemId === item.id` 将发票摘要渲染在对应明细内,摘要至少包含发票标识、销售方和金额;摘要整行是明确的键盘/触控预览入口,并复用现有 `openInvoiceMutation`。
- 未关联或关联目标已不存在的发票只出现在“报销材料”区,不猜测关联关系。材料区显示“发票 N · 补充附件 M”,并继续列出全部材料,方便主管快速通览以及处理未关联材料。
- 材料列表以带类型标签的文件信息行作为预览入口,不再给每份材料追加“查看原件”按钮。草稿编辑态的添加发票、移除发票、上传附件和删除附件仍作为独立显式动作,避免把预览与数据变更混在同一点击目标。
- 不新增材料浏览页、底部抽屉、上一份/下一份状态、批量下载、缩略图瀑布流或新的后端聚合接口。PC 与移动端保持相同信息结构,但沿用各自已有桌面表格和移动卡片组件。
- 可点击信息行必须有可见焦点、自然中文 `aria-label`,移动端触控目标不小于 44px;加载中禁用重复打开。企微仍走 `previewFile`,PC 仍走当前安全预览路径。
@@ -0,0 +1,215 @@
# 杨俊报销就绪:执行计划
## 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 质量门禁通过。
- [ ] 本地实现提交后,推送、公网部署与真人业务验收另行获得明确批准。
## 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;未部署、未读取或写入真实业务数据。
@@ -0,0 +1,107 @@
# 杨俊报销就绪
## 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 原则收敛为“概况 → 明细 → 发票 → 附件 → 审批记录”的业务主线:删除重复状态、重复标题和面向只读用户的操作教学;没有实际内容或动作的辅助区块不渲染,同时保留编辑态必要控件、风险提示和审计信息。
- 生产真机验收确认企微 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 必须在签发前执行当前对象级授权和下载理由检查,绑定文件与访问人、短时过期、不得包含长期凭据,并在实际取件时写审计。
- 生产可见性验收通过后发现报销详情仍把每张发票、每个补充附件都暴露为独立“查看原件”按钮,且费用明细与其 `linkedItemId` 发票分区展示,不利于财务逐条核对。PC 与移动端应统一采用“业务关联优先、材料入口收敛”的展示:每条明细直接显示并可预览其关联发票;全部发票和补充附件仍在单一“报销材料”区按类型汇总,文件信息行本身作为预览入口,不新增独立材料浏览路由、轮播器或后端模型。
## 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` 关联的发票摘要,点击该摘要可沿用现有安全预览能力打开原件;未关联发票不错误归入任何明细。
- [x] PC 与移动端将原“发票 / 附件材料”收敛为单一“报销材料”区,显示发票与补充附件计数;材料信息行可点击预览,不再为每份材料重复渲染“查看原件”按钮,同时保留草稿编辑态的添加、移除、上传和删除能力。
## 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`。
- 用合成多明细、已关联发票、未关联发票和补充附件验证 PC/移动一致性:关联发票出现在正确明细内;统一材料区计数正确且所有材料仍可预览;页面不再出现逐文件“查看原件”按钮;编辑态管理动作不回归。
@@ -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."}
@@ -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 或原始响应键。
@@ -0,0 +1,87 @@
# PC 企业微信扫码登录
## Goal
让已经完成 OA 与企业微信 userid 映射的员工,在普通 PC 浏览器访问 OA 登录页时,可以使用企业微信完成扫码或桌面端快速登录,不必输入 OA 密码或邮箱验证码;同时保留现有企业微信工作台免登链路。
## Scope
- 普通 PC 浏览器登录页的企微托管 Web 登录入口。
- 服务端企微 Web 登录 URL、入口来源、短期浏览器关联 cookie、共享 callback 和登录审计。
- 默认关闭的独立生产开关、配置/readiness 合同、回滚和真实扫码验收。
- 现有工作台免登、绑定、onboard、密码和邮箱验证码登录的回归保护。
## Background and Confirmed Facts
- 当前普通浏览器登录页只提供账号密码和企业邮箱验证码;企业微信入口文案要求用户从企微工作台打开 OA。
- 当前后端已具备 `code -> userid -> OA 绑定/唯一自动映射 -> 会话签发` 全链路,并已覆盖未映射、映射冲突、账号停用和成功登录。
- 当前移动/工作台入口使用企业微信“网页授权登录”;PC 普通浏览器应使用企业微信“Web 登录”入口,二者不是同一个授权地址。
- 企业微信官方文档《Web登录组件》(2026-01-14)支持两种网站接入:使用 `@wecom/jssdk >= 2.3.2` 内嵌登录组件,或构造企微托管 Web 登录链接。
- 自建应用 Web 登录使用 `login_type=CorpApp`、企业 CorpID、应用 AgentID、回调地址和 state;成功后回调携带 `code` 与原样 state。
- 企业微信官方文档《获取用户登录身份》(2026-04-29)确认 Web 登录 code 仍由 `/cgi-bin/auth/getuserinfo` 消费,code 只能使用一次,5 分钟未使用自动过期;现有后端兑换接口可复用。
- 首版采用点击按钮后跳转企业微信托管 Web 登录页,不在 OA 登录页内嵌企微 JS-SDK 或第三方脚本。这样不扩大密码与邮箱验证码页面的脚本信任边界,并让企微 CDN 或组件异常不影响 OA 既有登录表单。
- 现有 state 是 HMAC 签名的短期 payload,不是 JWT;它包含过期时间和随机 nonce,但 nonce 当前没有持久化消费,实施时必须验证扫码入口的 CSRF 与重放边界,不能仅凭 nonce 字段宣称一次性防重放。
- 当前 `mode` 表示 `login / bind / onboard` 业务流程。PC 扫码入口不得引入 `mode=qr`;入口来源应使用独立、固定白名单字段,例如 `entrySource=pc_qr | wecom_workbench`。
## Requirements
### R1 普通 PC 浏览器入口
- 普通 PC 浏览器登录页展示清晰的“企业微信扫码登录”入口。
- 点击入口后跳转企业微信托管 Web 登录页;OA 登录页不加载 `@wecom/jssdk`、企微 CDN 脚本或内嵌二维码 iframe。
- 入口不得取代账号密码和邮箱验证码等应急方式。
- 企业微信内置 WebView 继续使用当前工作台一键进入,不重复展示 PC 扫码入口。
### R2 复用既有身份与会话链路
- Web 登录成功后的 code 复用现有 userid 获取、OA 绑定查找、唯一自动映射、会话签发、设备指纹和安全回跳逻辑。
- 已绑定且 active 的员工可直接登录;不要求先用 OA 用户名密码完成首次绑定。
- 未映射、映射冲突、OA/企微账号停用时继续失败关闭,并显示现有可理解提示。
- 不改变 `bind` 和 `onboard` 两条业务流程。
### R3 入口来源与审计
- `mode` 保持业务语义,不承担入口区分。
- `entrySource` 只接受服务端固定白名单值,未知值不得进入审计或影响授权地址选择。
- 成功登录审计记录入口来源和既有 binding source,但不记录 code、state、userid、Secret 或原始企微响应。
### R4 安全边界
- state 必须服务端签名、短时有效、校验回调来源上下文,并继续限制 returnTo,防止开放重定向。
- PC 扫码请求必须通过短期 HttpOnly、SameSite=Lax 同站关联 cookie 将随机 nonce 与发起浏览器绑定;回调校验后立即清除该 cookie,正常浏览器重复回调在 OA 侧失败关闭,不新增数据库 nonce 表。若恶意客户端同时重放回调 URL 与旧 cookie,则由企微一次性 code 阻止第二次兑换和会话创建。
- 实施前通过测试明确验证 state 篡改、过期、入口来源伪造、code 缺失、企微拒绝授权和重复回调行为。
- 前端不得接触应用 Secret;Web 登录 URL 由服务端按受控配置构造。
### R5 配置、上线与回滚
- 生产启用前只读核对现有自建应用是否已开启“企业微信授权登录”,以及 OA 域名是否满足 OAuth 可信域名或 Web 网页授权回调域名要求。
- 若需要修改企微后台、生产环境变量、部署或真实扫码验收,必须另行获得明确批准。
- 功能应具备独立开关或可安全退回现有登录页的配置边界;回滚不得影响工作台免登、密码登录和邮箱验证码登录。
## Acceptance Criteria
- [x] 普通 PC 浏览器登录页出现明确的企业微信 Web 登录入口,企业微信内置 WebView 仍走原工作台入口。
- [x] 已映射 active 用户使用手机企微扫码后进入 OA,无需 OA 密码或首次绑定。
- [x] 企微托管 Web 登录页可按官方能力完成桌面端快速登录或手机扫码;本次已完成真实手机扫码验收。
- [x] 未映射、冲突、OA 停用和企微停用账号均由聚焦负例证明失败关闭,页面提示不引导用户先用密码绑定。
- [x] 生产登录成功审计明确记录 `pc_qr`,且只读检查确认不含 code、state、Secret、完整 userid 或原始 payload 键。
- [x] state 篡改、过期、非法入口来源、缺 code、重复使用 code 和恶意 returnTo 的自动化负例通过。
- [x] PC 扫码回调缺少、篡改或不匹配浏览器关联 cookie 时失败关闭且不调用企微 code 兑换接口;正常浏览器回调后 cookie 被清除,企微一次性 code 回归证明不会创建第二个会话。
- [x] 现有工作台登录、绑定、onboard、账号密码和邮箱验证码回归通过。
- [x] 本地后端/前端门禁、生产配置核验、公网构建标识、真实 PC 扫码、审计抽查和开关回滚检查均通过。
## Test Plan
- 后端单元测试覆盖 PC 配置和托管 URL、`entrySource` 白名单、state 和浏览器关联 cookie、安全回跳、成功/失败映射、审计脱敏及现有工作台/bind/onboard 回归。
- 前端测试与构建覆盖普通浏览器按钮、企微 WebView 入口隔离、配置关闭时降级,以及 returnTo/设备指纹传递。
- 执行 Prisma validate、前后端 typecheck/build、流程合同、文档审计、Trellis governance、task validate 和 `git diff --check`。
- 生产阶段另行批准后执行企微后台配置核验、公网 readiness/构建标识、现有登录回归、真实 PC 扫码、审计抽查和关闭开关回滚验证。
## Out of Scope
- 不建设新的 OA 账号体系,不要求逐人设置或分发初始密码。
- 不改变企微通讯录、userid 与 OA 账户的既有映射规则。
- 不扩展到微信个人号、第三方企业、外部联系人或多租户 SSO。
- 不在本任务重做找回密码、邮箱验证码或企业微信工作台页面体验。
- 不把 PC 扫码入口复用于绑定或新员工 onboard,除非后续任务单独批准。
- 不引入企微 JS-SDK、站内二维码 iframe,也不在本任务开启全站 CSP 改造;CSP 基线另行治理。
@@ -0,0 +1,26 @@
{
"id": "pc-wecom-qr-login",
"name": "pc-wecom-qr-login",
"title": "PC 企业微信扫码登录",
"description": "为普通 PC 浏览器增加企业微信 Web 扫码登录入口,复用现有 userid 映射、会话签发和审计链路。",
"status": "completed",
"dev_type": null,
"scope": "server,client,deploy,docs",
"package": "hyb-oa",
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-07-30",
"completedAt": "2026-07-30",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+7
查看文件
@@ -14,6 +14,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.62.0",
"@wecom/jssdk": "2.4.2",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3486,6 +3487,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@wecom/jssdk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@wecom/jssdk/-/jssdk-2.4.2.tgz",
"integrity": "sha512-zlvP0fpkfUjStQNCLynOZyk9jGtnYvbqI94e3/86EyAJexY0X/Bb6bzgTvrFFa6jpz8LG2DXTYSnFhibneWG1g==",
"license": "MIT"
},
"node_modules/@zxing/browser": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.1.5.tgz",
+1
查看文件
@@ -31,6 +31,7 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.62.0",
"@wecom/jssdk": "2.4.2",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+1 -1
查看文件
@@ -8,7 +8,7 @@ export type ApprovalRoleCode =
| "finance_mgr"
| "administration"
| "hr";
export type ApprovalOwnerSelfFallbackRoleCode = "finance_mgr";
export type ApprovalOwnerSelfFallbackRoleCode = "finance" | "finance_mgr";
export type ApprovalRule = {
id: string;
+16
查看文件
@@ -79,6 +79,8 @@ export type WecomEntryConfig = {
corpId: string;
agentId: string;
loginUrl: string | null;
pcWebLoginEnabled: boolean;
pcLoginUrl: string | null;
bindEnabled: boolean;
defaultReturnTo: string;
status: "ready" | "disabled" | "misconfigured";
@@ -97,6 +99,20 @@ export async function fetchWecomConfig() {
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
}
export type WecomJsSdkSignature = {
corpId: string;
timestamp: number;
nonceStr: string;
signature: string;
};
export async function fetchWecomJsSdkSignature(url: string) {
return apiRequestData<WecomJsSdkSignature>("/api/auth/wecom/js-sdk-signature", {
method: "POST",
body: JSON.stringify({ url }),
});
}
export async function startWecomBind(returnTo = "/settings/profile") {
return apiRequestData<{ loginUrl: string }>("/api/auth/wecom/bind", {
method: "POST",
+2 -2
查看文件
@@ -91,7 +91,7 @@ export type EmployeeSummary = {
id: string;
employeeNo: string;
name: string;
phone: string;
phone: string | null;
email: string;
status: EmployeeStatus;
departmentId: string;
@@ -231,7 +231,7 @@ export type EmployeeProfile = {
id: string;
employeeNo: string;
name: string;
phone: string;
phone: string | null;
email: string;
status: EmployeeStatus;
employmentStatus: EmployeeEmploymentStatus;
+7
查看文件
@@ -359,6 +359,13 @@ export function mapApiErrorMessage(
return "附件超过大小限制,请压缩后重新上传。";
}
if (details?.file === "too_many_files") {
const maxFiles = details?.maxFiles;
return typeof maxFiles === "number" && Number.isSafeInteger(maxFiles) && maxFiles > 0
? `单次最多上传 ${maxFiles} 个附件,请分批上传。`
: "一次选择的附件过多,请减少数量后分批上传。";
}
if (details?.file === "dangerous_filename") {
return "附件名称不安全,请重命名后重新上传。";
}
+7 -1
查看文件
@@ -254,7 +254,13 @@ export async function bindInvoice(
}
export async function fetchInvoiceSignedUrl(invoiceId: string) {
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
return apiRequestData<{
signedUrl: string;
expiresAt: string;
originalName: string | null;
mimeType: string | null;
fileSize: number | null;
}>(
`/api/invoice-pool/${invoiceId}/signed-url`,
{
method: "POST",
+21
查看文件
@@ -175,6 +175,8 @@ export type ReimbursementDetail = {
sellerName: string | null;
totalAmount: string | null;
originalName: string | null;
mimeType: string | null;
fileSize: number | null;
linkedItemId: string | null;
}>;
attachments: Array<{
@@ -490,6 +492,8 @@ export async function deleteReimbursementItem(reimbursementId: string, itemId: s
);
}
export const MAX_REIMBURSEMENT_ATTACHMENT_FILES = 5;
export async function uploadReimbursementAttachments(reimbursementId: string, files: File[]) {
const formData = new FormData();
for (const file of files) {
@@ -523,6 +527,23 @@ export async function downloadReimbursementAttachment(reimbursementId: string, a
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
}
export async function fetchReimbursementAttachmentPreviewUrl(
reimbursementId: string,
attachmentId: string,
reason?: string,
) {
return apiRequestData<{
signedUrl: string;
expiresAt: string;
originalName: string;
mimeType: string;
fileSize: number;
}>(`/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`, {
method: "POST",
body: JSON.stringify(reason ? { reason } : {}),
});
}
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
+1 -1
查看文件
@@ -35,7 +35,7 @@ export type MeResponse = {
id: string;
employeeNo: string;
name: string;
phone: string;
phone: string | null;
email: string;
departmentId: string;
directManagerId: string | null;
+2 -2
查看文件
@@ -6,7 +6,7 @@ export type UserSummary = {
id: string;
employeeNo: string;
name: string;
phone: string;
phone: string | null;
email: string;
status: UserStatus;
departmentId: string;
@@ -89,7 +89,7 @@ export async function fetchUsers(params: {
export async function createUser(payload: {
name: string;
phone: string;
phone: string | null;
email: string;
departmentId?: string;
directManagerId?: string | null;
@@ -0,0 +1,29 @@
import { Button } from "../ui/button";
import { usePwaUpdatePrompt } from "../../hooks/usePwaUpdatePrompt";
export function AppPwaUpdateNotice() {
const { updateReady, applyUpdate } = usePwaUpdatePrompt();
if (!updateReady) {
return null;
}
return (
<div
aria-label="系统更新"
className="mb-4 flex flex-col gap-3 rounded-xl border border-primary/20 bg-white px-4 py-3 text-sm text-slate-700 shadow-sm sm:flex-row sm:items-center sm:justify-between"
data-testid="app-pwa-update-notice"
role="region"
>
<div aria-live="polite">
<div className="font-semibold text-slate-900">发现新版本</div>
<div className="mt-1 leading-6">刷新后即可使用最新功能,当前页面不会自动重载。</div>
</div>
<div className="flex shrink-0 gap-2">
<Button className="min-w-24" type="button" onClick={applyUpdate}>
点击刷新
</Button>
</div>
</div>
);
}
+2
查看文件
@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { AppPwaUpdateNotice } from "../components/common/AppPwaUpdateNotice";
import { ErrorBoundary } from "../components/common/ErrorBoundary";
import { MobileBottomNav } from "../components/mobile/MobileBottomNav";
import { Sidebar } from "../components/navigation/Sidebar";
@@ -70,6 +71,7 @@ export function AppShellLayout() {
<div className={`flex-1 px-4 py-5 ${isMobileRoute ? `min-h-0 overscroll-contain overflow-y-auto px-[var(--mobile-page-x)] py-[var(--mobile-page-y)] ${showMobileBottomNav ? "pb-[calc(var(--mobile-bottom-nav-height,68px)+env(safe-area-inset-bottom,0px)+var(--mobile-page-y)+0.5rem)]" : "pb-5 sm:pb-6"}` : "sm:px-7 sm:py-7 lg:px-8"}`}>
<div className="layout-container max-w-none">
<AppPwaUpdateNotice />
<ErrorBoundary>
<Outlet />
</ErrorBoundary>

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