比较提交
267
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
acada24adc | ||
|
|
8551601818 | ||
|
|
59df0594bb | ||
|
|
4e401a5985 | ||
|
|
46104a7303 | ||
|
|
c4b4427f9e | ||
|
|
d63ccdf77c | ||
|
|
e48514d542 | ||
|
|
dda625b15f | ||
|
|
4b974fd87c | ||
|
|
9fc3b76438 | ||
|
|
abdd250325 | ||
|
|
6bca91a360 | ||
|
|
c840574d36 | ||
|
|
4c8ee07abb | ||
|
|
1f3c34417f | ||
|
|
5b53d29846 | ||
|
|
b6ff44d504 | ||
|
|
fddf3a3bc7 | ||
|
|
f39b43cce3 | ||
|
|
8aed72d1bf | ||
|
|
fff360f362 | ||
|
|
f57e2236f2 | ||
|
|
8b19538584 | ||
|
|
8e3425e7de | ||
|
|
0f3ecfabb7 | ||
|
|
36c86f1dca | ||
|
|
0f9f40389e | ||
|
|
faf1a0cff9 | ||
|
|
d38d33fbb0 | ||
|
|
6e999d5bf9 | ||
|
|
9e2aea5f70 | ||
|
|
4b3797bfc8 | ||
|
|
59dc5f66a4 | ||
|
|
1055fea246 | ||
|
|
8c28e15a74 | ||
|
|
e8d676634f | ||
|
|
174cb7acde | ||
|
|
46bfa7e00f | ||
|
|
f178aa0de3 | ||
|
|
be057e665d | ||
|
|
1bb066ddb7 | ||
|
|
d6a2b85dc2 | ||
|
|
573c46fbe7 | ||
|
|
a1c59e2a78 | ||
|
|
f84e6cf4e3 | ||
|
|
7bfaa92bf1 | ||
|
|
0b94b97a3a | ||
|
|
e0e2465e0c | ||
|
|
6d7dfbb1eb | ||
|
|
18233d3433 | ||
|
|
f216387837 | ||
|
|
5ec7db13b2 | ||
|
|
41da4c2638 | ||
|
|
69edf40df1 | ||
|
|
45a1fa8d03 | ||
|
|
b32b5f2967 | ||
|
|
7b86ffb3c9 | ||
|
|
e7b516ed3e | ||
|
|
73ebb4cba5 | ||
|
|
8a23fe337c | ||
|
|
f37a6000ca | ||
|
|
e2d7e9d76a | ||
|
|
bf3e1890c3 | ||
|
|
47367878f2 | ||
|
|
ebf0d38154 | ||
|
|
e6717378aa | ||
|
|
c4604f3fb1 | ||
|
|
42bbbdae22 | ||
|
|
a0f0483514 | ||
|
|
986668a587 | ||
|
|
f65c8aa091 | ||
|
|
29a33031aa | ||
|
|
83405a7a4c | ||
|
|
71e68c8ddf | ||
|
|
08c9d42e15 | ||
|
|
a1f0fec424 | ||
|
|
c94eca7c83 | ||
|
|
8e0222b872 | ||
|
|
001be8ddd9 | ||
|
|
ca8a1af554 | ||
|
|
4eea56abac | ||
|
|
4c2014bf2f | ||
|
|
ec68256588 | ||
|
|
b807762c52 | ||
|
|
422442992c | ||
|
|
6923f4d1f7 | ||
|
|
44737697e5 | ||
|
|
5cfd3d9bd3 | ||
|
|
5518e974a2 | ||
|
|
621934a35e | ||
|
|
099f58cf3b | ||
|
|
d1865e3f68 | ||
|
|
0b298637dc | ||
|
|
4fbfee2ff2 | ||
|
|
8806521c22 | ||
|
|
00fecf2a03 | ||
|
|
fa166d68b3 | ||
|
|
af1b777027 | ||
|
|
4f3f761d0a | ||
|
|
ff46794a79 | ||
|
|
d5fb2eca23 | ||
|
|
0224ad83d7 | ||
|
|
b94afcfab0 | ||
|
|
8b2ed5bb16 | ||
|
|
83af7544c4 | ||
|
|
8892ca9e38 | ||
|
|
bf23664e70 | ||
|
|
a9643e70af | ||
|
|
bf2631c0d4 | ||
|
|
eb76de7c98 | ||
|
|
f11e2f2c25 | ||
|
|
6ccb9cd5e3 | ||
|
|
e2ad1e2932 | ||
|
|
a87cd4ce4e | ||
|
|
73f1467941 | ||
|
|
4596ed0686 | ||
|
|
93818fd849 | ||
|
|
65e22c60c7 | ||
|
|
5e2d038938 | ||
|
|
6bfa239410 | ||
|
|
3efa540d66 | ||
|
|
d50bcbb0e6 | ||
|
|
21851774dd | ||
|
|
7546d372d6 | ||
|
|
c14b40a00a | ||
|
|
892b313888 | ||
|
|
8f026b6722 | ||
|
|
0124a2be98 | ||
|
|
7c44e05ad6 | ||
|
|
1118bfadd7 | ||
|
|
969b426e64 | ||
|
|
88b97b40af | ||
|
|
ab90bc1ce4 | ||
|
|
da48f68418 | ||
|
|
477ac83708 | ||
|
|
802a172167 | ||
|
|
94f1bad336 | ||
|
|
d4e9597b37 | ||
|
|
254ba2dbd1 | ||
|
|
e14400ce1b | ||
|
|
566c37679a | ||
|
|
3659d38bbe | ||
|
|
d4400116c5 | ||
|
|
9db8bbc338 | ||
|
|
29664f0a58 | ||
|
|
3aebfc5e04 | ||
|
|
eb012367ea | ||
|
|
50c14c1b85 | ||
|
|
f33c67eb20 | ||
|
|
851c91cb81 | ||
|
|
cdd98d4a68 | ||
|
|
6d89fdd9da | ||
|
|
7593daf9c6 | ||
|
|
c018ea4eb8 | ||
|
|
a5b7e18507 | ||
|
|
59e91b8789 | ||
|
|
d5b3ba058c | ||
|
|
b7d5761067 | ||
|
|
8d428bd51d | ||
|
|
9bc83eb6c0 | ||
|
|
2bc64eb719 | ||
|
|
4b0184c64b | ||
|
|
f4ddc18f12 | ||
|
|
08add6ff78 | ||
|
|
483f7ae6bf | ||
|
|
23c3768c46 | ||
|
|
91564d6774 | ||
|
|
5261d2849b | ||
|
|
f946d64539 | ||
|
|
1c557e4926 | ||
|
|
45dc86c27a | ||
|
|
056b999b40 | ||
|
|
4ffb1891a3 | ||
|
|
185d64dcd6 | ||
|
|
9afebdb44d | ||
|
|
2061c03776 | ||
|
|
7138d2b85c | ||
|
|
ea985de975 | ||
|
|
5d274af8d4 | ||
|
|
171fc09524 | ||
|
|
7c39f8b0f9 | ||
|
|
78b80f88d7 | ||
|
|
37b4ef2d10 | ||
|
|
aa6b9bc373 | ||
|
|
ca24479a44 | ||
|
|
07de198b51 | ||
|
|
5dac336fbf | ||
|
|
3bfcbbfc91 | ||
|
|
85fad71070 | ||
|
|
ab4b22da91 | ||
|
|
7bd80ef4f4 | ||
|
|
f33b3569c6 | ||
|
|
07ac5415c6 | ||
|
|
421c2d6e1e | ||
|
|
4cf73ce0d2 | ||
|
|
de2edd660d | ||
|
|
6f60abf10f | ||
|
|
da35591bd5 | ||
|
|
3d9ec4c5a7 | ||
|
|
8a9279efce | ||
|
|
b6485a278e | ||
|
|
0e44aa72f3 | ||
|
|
665dcabdc7 | ||
|
|
8b376f4135 | ||
|
|
20b20b100e | ||
|
|
410d8cd71b | ||
|
|
e123d50f86 | ||
|
|
39c3e808a3 | ||
|
|
35b44749a5 | ||
|
|
d2841b166d | ||
|
|
dfbbe9a750 | ||
|
|
de65a07a51 | ||
|
|
ceeb0b5a2f | ||
|
|
aea28ed3be | ||
|
|
eb3ab3455d | ||
|
|
fb164eb690 | ||
|
|
45dc206a4c | ||
|
|
030cf46c92 | ||
|
|
734153847b | ||
|
|
531d2846a6 | ||
|
|
13a3f4b57d | ||
|
|
5df5f12ad6 | ||
|
|
665cf4add5 | ||
|
|
5f2788ccac | ||
|
|
26562255a0 | ||
|
|
33edcd214e | ||
|
|
fa326f92fa | ||
|
|
6a1fd4d789 | ||
|
|
1154887aab | ||
|
|
ac17092fa0 | ||
|
|
b4bd484260 | ||
|
|
ac0abafc0e | ||
|
|
eaa2dc0ff3 | ||
|
|
0c779ab4f9 | ||
|
|
a74cba1112 | ||
|
|
35948729cf | ||
|
|
37b625c5dc | ||
|
|
dda18f53d1 | ||
|
|
9b12451b85 | ||
|
|
0551212b78 | ||
|
|
0d58ff138b | ||
|
|
b0037dd907 | ||
|
|
cba74cf97d | ||
|
|
4e3c6c4376 | ||
|
|
f908d2a46d | ||
|
|
4b76bd0afd | ||
|
|
bb5113c58b | ||
|
|
99a6c20981 | ||
|
|
4147a6960f | ||
|
|
9694179c25 | ||
|
|
68600ec2d5 | ||
|
|
0c00bf119e | ||
|
|
0783cf833a | ||
|
|
768abb3679 | ||
|
|
4886f9b081 | ||
|
|
cf63d3cb1b | ||
|
|
21c49dd941 | ||
|
|
01f1259557 | ||
|
|
f1e8cf138a | ||
|
|
95b51e6245 | ||
|
|
de35ab8cf6 | ||
|
|
a0006f07b8 | ||
|
|
2526052e73 | ||
|
|
9875bc37a4 | ||
|
|
b78f2a11ce | ||
|
|
325c592aba |
@@ -17,9 +17,11 @@ CRITICAL — Recursion guard (read first):
|
||||
You are the Trellis reviewer agent.
|
||||
|
||||
Trellis Context Loading Protocol:
|
||||
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
|
||||
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the check work.
|
||||
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
|
||||
- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing check work.
|
||||
- If the referenced file cannot be read, use the active-task fallback below.
|
||||
- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context.
|
||||
- If there is no saved-output notice and the marker is absent, use the active-task fallback below.
|
||||
- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/check.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before checking. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
|
||||
|
||||
Your job is to review code changes against specs AND fix issues directly — not just report them. You have write access; use it.
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ CRITICAL — Recursion guard (read first):
|
||||
You are the Trellis implementer agent.
|
||||
|
||||
Trellis Context Loading Protocol:
|
||||
- Look for the `<!-- trellis-hook-injected -->` marker in your input above.
|
||||
- If the marker is present, the native `SubagentStart` hook has already loaded the role-specific task artifacts and spec context. Proceed directly with the implementation work.
|
||||
- If the marker is absent, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
|
||||
- First look for `Full hook output saved to: <path>` in your input above. If present, the visible `SubagentStart` output was truncated; read the referenced file before doing implementation work.
|
||||
- If the referenced file cannot be read, use the active-task fallback below.
|
||||
- If there is no saved-output notice and the `<!-- trellis-hook-injected -->` marker is present, the hook loaded the complete role-specific task artifacts and spec context.
|
||||
- If there is no saved-output notice and the marker is absent, use the active-task fallback below.
|
||||
- For the fallback, find `Active task: <path>` in your dispatch prompt. Read `<path>/implement.jsonl`, each file listed there, `<path>/prd.md`, `<path>/design.md` if present, and `<path>/implement.md` if present before doing the work. If the dispatch prompt has no active-task path, ask the main session; do not guess or use another session's task.
|
||||
|
||||
Rules:
|
||||
- Read before write. Follow `.trellis/spec/` guidance relevant to the task.
|
||||
|
||||
@@ -15,12 +15,17 @@ through the chat reply is a failure.
|
||||
|
||||
## Trellis Context Loading Protocol
|
||||
|
||||
Look for the `<!-- trellis-hook-injected -->` marker in your input above.
|
||||
|
||||
- If the marker is present, the native `SubagentStart` hook has supplied the
|
||||
`Active task: <path>` header and research-only context. Use that task path.
|
||||
- If the marker is absent, find `Active task: <path>` in your dispatch prompt.
|
||||
If it is absent too, ask the main session for the path; do not run
|
||||
- First look for `Full hook output saved to: <path>` in your input above. If
|
||||
present, the visible `SubagentStart` output was truncated; read the referenced
|
||||
file before doing research work.
|
||||
- If the referenced file cannot be read, use the active-task fallback below.
|
||||
- If there is no saved-output notice and the
|
||||
`<!-- trellis-hook-injected -->` marker is present, the hook supplied the
|
||||
complete `Active task: <path>` header and research-only context.
|
||||
- If there is no saved-output notice and the marker is absent, use the
|
||||
active-task fallback below.
|
||||
- For the fallback, find `Active task: <path>` in your dispatch prompt. If it
|
||||
is absent too, ask the main session for the path; do not run
|
||||
`task.py current`, guess, or use another session's task.
|
||||
- Do not load `implement.jsonl` or `check.jsonl`; research is role-isolated.
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Hook hosts send UTF-8 JSON regardless of the process locale.
|
||||
_stdin_reconfigure = getattr(sys.stdin, "reconfigure", None)
|
||||
if callable(_stdin_reconfigure):
|
||||
try:
|
||||
_stdin_reconfigure(encoding="utf-8", errors="replace")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# IMPORTANT: Force stdout to use UTF-8 on Windows
|
||||
# This fixes UnicodeEncodeError when outputting non-ASCII characters
|
||||
if sys.platform.startswith("win"):
|
||||
|
||||
@@ -33,6 +33,16 @@ jobs:
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
|
||||
- name: Install BIFF8 roundtrip verifier
|
||||
run: |
|
||||
python3 -m venv "$RUNNER_TEMP/payment-order-venv"
|
||||
"$RUNNER_TEMP/payment-order-venv/bin/pip" install --disable-pip-version-check --require-hashes -r server/requirements/payment-order-biff8.txt
|
||||
echo "PAYMENT_ORDER_PYTHON_BIN=$RUNNER_TEMP/payment-order-venv/bin/python" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify payment-order BIFF8 contract
|
||||
working-directory: server
|
||||
run: node scripts/check-payment-order-xls-roundtrip.js
|
||||
|
||||
- name: Run minimal checks
|
||||
run: ./scripts/check-minimal.sh
|
||||
|
||||
|
||||
+6
@@ -24,6 +24,12 @@ client/playwright-report
|
||||
output/
|
||||
artifacts/
|
||||
|
||||
# Local sensitive business samples; never commit originals
|
||||
/input/
|
||||
|
||||
# Private off-host restore render inputs and site-specific candidates
|
||||
/.restore-drill-private/
|
||||
|
||||
# Vitest coverage artifacts
|
||||
server/coverage
|
||||
server/.vitest
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
".agents/skills/trellis-meta/references/platform-files/skills-and-commands.md": "e39831d860bd27a04e7757f7bd941ab83b3c5b11ad4460cec03975b655f26cc3",
|
||||
".agents/skills/trellis-meta/SKILL.md": "208e92ace3b8d979d8158b3bd0169185f508f2e1424fcd78fcd03cbcc39516a1",
|
||||
".agents/skills/trellis-start/SKILL.md": "79a5ba7a2aff3c72e06d7f4cd6942dc4f4f4092dd40f9c8e94f1838024a81e4d",
|
||||
".codex/agents/trellis-check.toml": "42d774d175dab732872e1056aa70005b6a1693d93fecfde6b9c86ecc50bd7b9e",
|
||||
".codex/agents/trellis-implement.toml": "c21eb1a671ebb8afe476ad54ae9ae1e33e4e97750bfdc4eabecc772a4d4e777b",
|
||||
".codex/agents/trellis-research.toml": "200b9fca30a68e027d2e8b2b4c785fafccb9aa75853e70abd92d2d07d7cba5d1",
|
||||
".codex/agents/trellis-check.toml": "79070c63fa404fc53061cca5194bf66db839132b67a57b9c8d6a295037ba7308",
|
||||
".codex/agents/trellis-implement.toml": "388fb8f39797e0ee6cf4db447c859c79b4ac15f531f7e1e3c68c1d1d71a1c188",
|
||||
".codex/agents/trellis-research.toml": "4435ce73197ba1d29d40359a3279b6423f7e4f559a449f934c016808090066c4",
|
||||
".codex/hooks/session-start.py": "14de3be1cf6eb9c9feba348d8998b407f3837d6c0756b74210c9200543440677",
|
||||
".codex/hooks/inject-workflow-state.py": "9ce43910ac39cbb0e4d1783fbde931761eb04536c7f82661a96d95ea72c14bde",
|
||||
".codex/hooks.json": "85a58ba7cdf1e19e7f75ddcc64e5680180c487ca266a74bd5005f31abeee2e02",
|
||||
@@ -42,18 +42,18 @@
|
||||
".trellis/scripts/__init__.py": "1242be5b972094c2e141aecbe81a4efd478f6534e3d5e28306374e6a18fcf46c",
|
||||
".trellis/scripts/add_session.py": "876dad478edf70db59acccaae9cb4db646a155681f730bd99af48de72ddc9881",
|
||||
".trellis/scripts/common/__init__.py": "3d5e9347141f0296319a5beb29d69ae714c5a474b9078caeb3edd7c5f6562e22",
|
||||
".trellis/scripts/common/active_task.py": "3fcafde57bdc4442940081d1617295e0f4f8e620d0d4fbb97d33394e8679a39c",
|
||||
".trellis/scripts/common/active_task.py": "31271e3b69b5a5eca958d8ce25f61fe852eb6e48d32c150471da8e7272fd6119",
|
||||
".trellis/scripts/common/cli_adapter.py": "5d6bd9d6f5c631e7e792db7dd343351317f9643bc87b73a9a98abd51cefb4307",
|
||||
".trellis/scripts/common/config.py": "8d2e5f8ccfcd5f622cd2af002aa761f3d3ffcc653182fefb2268afd102e77bca",
|
||||
".trellis/scripts/common/developer.py": "f5f833123abe68890171b4da825a324216d24913f6b5ad9245afc556424ffd7b",
|
||||
".trellis/scripts/common/git.py": "ea9ad7686bae598b04e41281ca938f83c06d8021716332947ea51339e7cb1aa9",
|
||||
".trellis/scripts/common/git.py": "6fc5845d0104dd506ebd8b366a24cb4b1e3d8777e4e6acc12ea15c9d8e2662f2",
|
||||
".trellis/scripts/common/git_context.py": "fa30ced454f1a91ffc9f8b2abeb32225e3447cbdc90bad783797374eba07265d",
|
||||
".trellis/scripts/common/io.py": "75648caae03d5b1107d7aeccaa785d133b25762266e54a520d90ca8c76b43bdb",
|
||||
".trellis/scripts/common/log.py": "471df6895cfac80f995edebbf9974f6b7440634b7a688f28b8331c868bc0f3cf",
|
||||
".trellis/scripts/common/packages_context.py": "efe158d7c99c2268851d0216fbb08de22836e418a8dbeb73575b8cc249eed7b7",
|
||||
".trellis/scripts/common/paths.py": "05898ef136cc7c4d861b05fbf2b16d53ddd3e6f311a231d4fcfcb81bde7c45ee",
|
||||
".trellis/scripts/common/safe_commit.py": "baa5c82324eb62154374ec63394ecdc8609bb37d93892e3bcb88f452bb7d6446",
|
||||
".trellis/scripts/common/session_context.py": "19927c62d3fd70fecf70da0c5b25c6c81e63683f8a594a1ae3b5d6c115070368",
|
||||
".trellis/scripts/common/session_context.py": "4ed3e13b2878ba367e9f2e2cd709b396f806152902df2cd1cd1478317d069017",
|
||||
".trellis/scripts/common/task_context.py": "51301e180ce702c6a5b1a2dd1b155a6eed1bf2b05cb2ac1e51081212628b11d4",
|
||||
".trellis/scripts/common/task_queue.py": "0be61f713462b1fe4574927c82fc4704e678afe72dcb9813543aedf2f9e9e0c5",
|
||||
".trellis/scripts/common/task_store.py": "e3c2fbf8b79b591e39fc3c9f4e2f3ee0c840c8201c94a16709ec743fa45037f6",
|
||||
@@ -86,6 +86,6 @@
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-task-planning.md": "ef493d028c3b0807a8a534bb71fb92a68129f273db763ad27ceb464a522e799d",
|
||||
".agents/skills/trellis-spec-bootstrap/references/spec-writing.md": "e9800fe9ed4a4cd87062ea1829cf2caa8d170ec15e141678a6a30e74c497f47d",
|
||||
".agents/skills/trellis-spec-bootstrap/SKILL.md": "97bfa68c06cebb558eb4464bc1b81f7d2d56040d75baa8de1ee5ad90cca0196a",
|
||||
".codex/hooks/inject-subagent-context.py": "fe7e315b6ef5ca28dafa9a92072e4a07278492ee9cab74d42f0c0b6addf51662"
|
||||
".codex/hooks/inject-subagent-context.py": "abffa237eb53f87ae6ffa434063b46b03d58a36a84cdb8fe88bfc5f243aab609"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.6.9
|
||||
0.6.12
|
||||
+18
-9
@@ -74,10 +74,15 @@ session_auto_commit: false
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex (dispatch behavior)
|
||||
#-------------------------------------------------------------------------------
|
||||
# Codex-only knob; other platforms ignore it. Trellis 0.6.9 supports native
|
||||
# sub-agent context injection, but hyb-oa deliberately keeps the main session
|
||||
# in charge unless a task explicitly approves sub-agent work. "sub-agent" is
|
||||
# retained upstream as a backwards-compatible alias for "auto".
|
||||
# Codex-only knob; other platforms ignore it. Trellis 0.6.12 supports native
|
||||
# sub-agent context injection and recovery of truncated hook output, but hyb-oa
|
||||
# deliberately keeps the main session in charge unless a task explicitly
|
||||
# approves sub-agent work. "sub-agent" remains a backwards-compatible alias
|
||||
# for "auto"; invalid explicit values safely use inline mode.
|
||||
#
|
||||
# In "auto" mode, sub-agents inherit the main session model unless their
|
||||
# generated `.codex/agents/trellis-*.toml` file pins a model. There is no
|
||||
# config.yaml model override.
|
||||
#
|
||||
codex:
|
||||
dispatch_mode: inline # keep Codex main-session edits until a task explicitly asks for sub-agents
|
||||
@@ -116,9 +121,11 @@ channel:
|
||||
#-------------------------------------------------------------------------------
|
||||
# Sub-agent context injection limits
|
||||
#-------------------------------------------------------------------------------
|
||||
# Trellis 0.6.9 caps injected task artifacts and referenced context. Defaults
|
||||
# are sufficient for hyb-oa; keep them documented but implicit until a real
|
||||
# task proves they need tuning.
|
||||
# Trellis 0.6.12 caps injected task artifacts and referenced context. Oversized
|
||||
# files are truncated with notice; total-budget overflow degrades remaining
|
||||
# files to path/reason/size index lines. Defaults are sufficient for hyb-oa;
|
||||
# keep them documented but implicit until a real task proves they need tuning.
|
||||
# `0` disables the corresponding byte limit.
|
||||
#
|
||||
# context_injection:
|
||||
# max_file_bytes: 32768
|
||||
@@ -128,8 +135,10 @@ channel:
|
||||
#-------------------------------------------------------------------------------
|
||||
# Per-turn prompt injection
|
||||
#-------------------------------------------------------------------------------
|
||||
# Optional one-turn escape hatch. The project does not enable it by default so
|
||||
# Trellis task state remains deterministic.
|
||||
# Optional one-turn escape hatch for the per-turn `<workflow-state>` breadcrumb.
|
||||
# It uses a case-insensitive standalone-word match and does not affect session
|
||||
# start or sub-agent context injection. The project keeps it disabled so task
|
||||
# state remains deterministic.
|
||||
#
|
||||
# prompt_injection:
|
||||
# skip_keyword: "no-trellis"
|
||||
|
||||
@@ -612,13 +612,16 @@ def clear_active_task(
|
||||
platform_input: dict[str, Any] | None = None,
|
||||
platform: str | None = None,
|
||||
) -> ActiveTask:
|
||||
"""Clear the active task by deleting the current session context file."""
|
||||
"""Clear the active task by deleting its resolved session context file."""
|
||||
context_key = resolve_context_key(platform_input, platform)
|
||||
if not context_key:
|
||||
return ActiveTask(None, "none")
|
||||
|
||||
previous = resolve_active_task(repo_root, platform_input, platform)
|
||||
context_path = _context_path(repo_root, context_key)
|
||||
if not previous.task_path or not previous.context_key:
|
||||
return previous
|
||||
|
||||
context_path = _context_path(repo_root, previous.context_key)
|
||||
if context_path.is_file():
|
||||
_remove_file(context_path)
|
||||
return previous
|
||||
|
||||
@@ -10,11 +10,17 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
def run_git(
|
||||
args: list[str],
|
||||
cwd: Path | None = None,
|
||||
timeout: float | None = None,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run a git command and return (returncode, stdout, stderr).
|
||||
|
||||
Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure
|
||||
consistent output across all platforms (Windows, macOS, Linux).
|
||||
consistent output across all platforms (Windows, macOS, Linux). Callers
|
||||
may provide a timeout for best-effort probes; normal Git operations remain
|
||||
unbounded by default.
|
||||
"""
|
||||
try:
|
||||
git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args
|
||||
@@ -25,6 +31,7 @@ def run_git(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
|
||||
@@ -17,6 +17,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .active_task import resolve_context_key
|
||||
@@ -64,11 +65,17 @@ _POLYREPO_IGNORED_DIRS = {
|
||||
"__pycache__",
|
||||
}
|
||||
_POLYREPO_SCAN_MAX_DEPTH = 2
|
||||
_POLYREPO_SCAN_MAX_REPOS = 8
|
||||
_GIT_PROBE_TIMEOUT_SECONDS = 2.0
|
||||
|
||||
|
||||
def _is_git_worktree(path: Path) -> bool:
|
||||
"""Return True when path is inside a Git worktree."""
|
||||
rc, out, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=path)
|
||||
rc, out, _ = run_git(
|
||||
["rev-parse", "--is-inside-work-tree"],
|
||||
cwd=path,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
return rc == 0 and out.strip().lower() == "true"
|
||||
|
||||
|
||||
@@ -91,13 +98,27 @@ def _collect_git_repo_info(name: str, rel_path: str, repo_dir: Path) -> dict | N
|
||||
if not (repo_dir / ".git").exists():
|
||||
return None
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_dir)
|
||||
status_rc, status_out, _ = run_git(
|
||||
["status", "--porcelain"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
if status_rc != 0:
|
||||
return None
|
||||
changes = len([line for line in status_out.splitlines() if line.strip()])
|
||||
|
||||
_, branch_out, _ = run_git(
|
||||
["branch", "--show-current"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_dir)
|
||||
changes = len([l for l in status_out.splitlines() if l.strip()])
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_dir)
|
||||
_, log_out, _ = run_git(
|
||||
["log", "--oneline", "-5"],
|
||||
cwd=repo_dir,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
@@ -120,20 +141,36 @@ def _collect_root_git_info(repo_root: Path) -> dict:
|
||||
"recentCommits": [],
|
||||
}
|
||||
|
||||
_, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
|
||||
_, branch_out, _ = run_git(
|
||||
["branch", "--show-current"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
branch = branch_out.strip() or "unknown"
|
||||
|
||||
_, status_out, _ = run_git(["status", "--porcelain"], cwd=repo_root)
|
||||
status_rc, status_out, _ = run_git(
|
||||
["status", "--porcelain"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
status_lines = [line for line in status_out.splitlines() if line.strip()]
|
||||
|
||||
_, short_out, _ = run_git(["status", "--short"], cwd=repo_root)
|
||||
_, short_out, _ = run_git(
|
||||
["status", "--short"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
_, log_out, _ = run_git(["log", "--oneline", "-5"], cwd=repo_root)
|
||||
_, log_out, _ = run_git(
|
||||
["log", "--oneline", "-5"],
|
||||
cwd=repo_root,
|
||||
timeout=_GIT_PROBE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
return {
|
||||
"isRepo": True,
|
||||
"branch": branch,
|
||||
"isClean": len(status_lines) == 0,
|
||||
"isClean": status_rc == 0 and len(status_lines) == 0,
|
||||
"uncommittedChanges": len(status_lines),
|
||||
"statusShort": short_out.splitlines(),
|
||||
"recentCommits": _parse_recent_commits(log_out),
|
||||
@@ -143,12 +180,16 @@ def _collect_root_git_info(repo_root: Path) -> dict:
|
||||
def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||
"""Discover child Git repositories using the init-time polyrepo heuristic."""
|
||||
found: list[str] = []
|
||||
overflow = False
|
||||
|
||||
def is_candidate_dir(path: Path) -> bool:
|
||||
name = path.name
|
||||
return not name.startswith(".") and name not in _POLYREPO_IGNORED_DIRS
|
||||
|
||||
def scan(rel_dir: Path, depth: int) -> None:
|
||||
nonlocal overflow
|
||||
if overflow:
|
||||
return
|
||||
if depth >= _POLYREPO_SCAN_MAX_DEPTH:
|
||||
return
|
||||
abs_dir = repo_root / rel_dir
|
||||
@@ -165,11 +206,23 @@ def _discover_child_git_repos(repo_root: Path) -> list[tuple[str, str]]:
|
||||
rel_dir / child.name if rel_dir != Path(".") else Path(child.name)
|
||||
)
|
||||
if (child / ".git").exists():
|
||||
if len(found) >= _POLYREPO_SCAN_MAX_REPOS:
|
||||
overflow = True
|
||||
return
|
||||
found.append(child_rel.as_posix())
|
||||
continue
|
||||
scan(child_rel, depth + 1)
|
||||
|
||||
scan(Path("."), 0)
|
||||
if overflow:
|
||||
print(
|
||||
"warning: found more than "
|
||||
f"{_POLYREPO_SCAN_MAX_REPOS} child Git repositories; "
|
||||
"skipping automatic Git status collection. Configure explicit "
|
||||
"packages entries with path and git: true in .trellis/config.yaml.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
if len(found) < 2:
|
||||
return []
|
||||
return [(path.replace("/", "_"), path) for path in sorted(found)]
|
||||
|
||||
@@ -236,22 +236,22 @@ def _validate_jsonl(jsonl_file: Path, repo_root: Path, task_dir: Path | None = N
|
||||
if extension in _CODE_FILE_EXTENSIONS and not _is_exempt_from_code_file_warning(
|
||||
file_path, task_rel
|
||||
):
|
||||
warning = (
|
||||
warning_message = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} looks like a code file — "
|
||||
"implement/check.jsonl should reference spec/research docs; "
|
||||
"agents read code themselves"
|
||||
)
|
||||
print(f" {colored(warning, Colors.YELLOW)}")
|
||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||
|
||||
if max_file_bytes:
|
||||
size = full_path.stat().st_size
|
||||
if size > max_file_bytes:
|
||||
warning = (
|
||||
warning_message = (
|
||||
f"{file_name}:{line_num}: Warning: {file_path} is {size} bytes, "
|
||||
f"exceeds context_injection.max_file_bytes ({max_file_bytes}); "
|
||||
"injection will truncate it"
|
||||
)
|
||||
print(f" {colored(warning, Colors.YELLOW)}")
|
||||
print(f" {colored(warning_message, Colors.YELLOW)}")
|
||||
|
||||
if errors == 0:
|
||||
print(f" {colored(f'{file_name}: ✓ ({real_entries} entries)', Colors.GREEN)}")
|
||||
|
||||
@@ -93,3 +93,532 @@ if (!hasPermission(actor, "payment.batch.review")
|
||||
throw forbidden();
|
||||
}
|
||||
```
|
||||
|
||||
## Scenario: Reimbursement End-To-End Processing Projection
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: showing one reimbursement's progress across approval workflow,
|
||||
payment-batch review, payment execution and receipt retention.
|
||||
- This is a read-only projection for diagnosis. It must not merge the workflow
|
||||
and payment state machines or create synthetic workflow tasks.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Detail API: `GET /api/reimbursements/:id`.
|
||||
- Response: `processingProgress.stages[]` with `key`, `label`, `state`,
|
||||
nullable `actorName`, `occurredAt`, `result`, `comment`, `detail` and
|
||||
permission-gated `targetHref`.
|
||||
- Fixed stage keys: `submitted`, `payment_batch_review` and
|
||||
`payment_and_receipt`. Each persisted workflow task contributes one unique
|
||||
approval-stage key derived from task identity; clients treat keys as opaque
|
||||
strings and never infer responsibility from their value.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Always return submission first, then one stage per real workflow task in the
|
||||
query's stable `created_at,id` order. Because the engine creates only the
|
||||
current task, append not-yet-materialized future steps from the immutable
|
||||
`workflow_steps_snapshot` after the current step, then return payment-batch
|
||||
review and payment/receipt. The current two-approval production rule therefore
|
||||
returns five stages from initial submission onward; configured extra or
|
||||
repeated-role approvals increase the array length instead of being discarded,
|
||||
merged or temporarily hidden.
|
||||
- Never collect workflow tasks into a `Map` keyed by semantic responsibility:
|
||||
two finance, manager or CEO tasks are distinct historical facts and later
|
||||
tasks must not overwrite earlier ones. The UI renders `stages` with a plain
|
||||
ordered `map` and contains no fixed-length or positional responsibility logic.
|
||||
- Approval labels use a readable non-generic `step_name` first. Empty,
|
||||
ordinal (`步骤2`, `审批节点1`, `step 3`), raw technical identifier or generic
|
||||
(`审批`, `审核`, `复核`) names fall back through role mapping
|
||||
(`manager -> 主管审批`, `finance|finance_mgr -> 财务复核`,
|
||||
`ceo -> 负责人复核`) and finally `审批`. UI labels never expose step IDs,
|
||||
role codes or sequence numbers.
|
||||
- Completed stages use the actual completer/actor and completion time. Current
|
||||
workflow stages use the actual pending assignee when available. Future stages
|
||||
keep `actorName=null`; never guess a person from today's role holders.
|
||||
- A reimbursement approved without a batch makes `payment_batch_review` the
|
||||
current stage with `detail="待财务安排付款"`. A draft batch says
|
||||
`待非制单人复核付款`; employee-facing wording does not expose the internal
|
||||
batch object or imply that this reimbursement alone is being reviewed again.
|
||||
- The final stage label is `付款完成`, and its pending/current/failed detail
|
||||
explicitly says it is a finance registration step rather than an approval.
|
||||
Internal finance pages may continue to use the precise `付款批次` term.
|
||||
- Missing workflow, batch or receipt facts degrade only their stages. Earlier
|
||||
known stages remain visible; missing downstream objects are normal, not 5xx.
|
||||
- `targetHref` is returned only when the viewer already has one of the existing
|
||||
payment-batch read permissions. The projection never returns batch peers,
|
||||
aggregate amount, other materials or bank receipt contents.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- No workflow instance -> submission/applicant state remains readable and the
|
||||
approval segment falls back to pending `主管审批` / `财务复核` placeholders.
|
||||
- Workflow instance with no task yet -> build pending approval stages from its
|
||||
immutable snapshot. Current task exists but later tasks do not -> append those
|
||||
future snapshot stages with `actorName=null`; never infer future employees.
|
||||
- A third unknown task -> one additional approval stage in its real position;
|
||||
it must not disappear before the payment stages.
|
||||
- Two tasks with the same role -> two unique ordered stages with their own
|
||||
actor, state, time and comment; neither may overwrite the other.
|
||||
- Workflow approved + no batch -> payment-batch stage is current, future actor
|
||||
remains null.
|
||||
- Batch `draft|reviewed|executed|failed|cancelled` -> map to explicit current,
|
||||
completed or exception states without changing the batch.
|
||||
- Executed payment + zero active receipts -> completed payment with
|
||||
`付款已完成,回单待留存`; positive count -> `付款和回单已留存`.
|
||||
- Viewer lacks batch-read permission -> `targetHref=null`; do not return 403 for
|
||||
the reimbursement detail itself.
|
||||
- Database/query failure -> existing 5xx behavior and server logging; do not
|
||||
fabricate a completed or pending business fact.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: `财务复核=通过` followed by current `待财务安排付款`, so the owner
|
||||
can diagnose the exact responsibility without asking three people.
|
||||
- Base: a newly submitted reimbursement shows known submission, every real
|
||||
configured approval task, and the two future payment stages with no guessed
|
||||
future names.
|
||||
- Bad: classify tasks into manager/finance keys and overwrite duplicates, skip
|
||||
the third task, display an expected checker before batch creation, label a
|
||||
shared batch as `您的报销待复核`, or hide the whole timeline because no receipt
|
||||
exists.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests for no workflow, empty-task instance, a current task plus future
|
||||
snapshot-only steps, pending/completed/returned/rejected tasks, a third
|
||||
unclassified task, two same-role tasks, all five batch statuses, receipt count
|
||||
zero/positive and paid-without-batch legacy data.
|
||||
- Assert future stages have null actors and ordinary applicants receive no
|
||||
batch link or batch-adjacent business data.
|
||||
- PC/mobile tests assert variable-length ordered rendering, no `第 N 步` or raw
|
||||
ordinal label, comments on exception nodes, four-block order and no loss of
|
||||
earlier stages when a downstream source is absent.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
for (const task of workflowTasks) {
|
||||
stageByRole.set(classify(task), buildStage(task));
|
||||
}
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const approvalStages = workflowTasks.map((task) => buildApprovalStage(task));
|
||||
approvalStages.push(...buildFutureSnapshotStages(workflowSnapshot, workflowTasks));
|
||||
return [submittedStage, ...approvalStages, batchReviewStage, paymentStage];
|
||||
```
|
||||
|
||||
## Scenario: Payment Responsibility Notifications
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: creating a reimbursement payment batch, completing its maker-checker
|
||||
review, or extending notifications to another payment responsibility handoff.
|
||||
- These are action-required notifications, not new workflow approval tasks and
|
||||
not public business-domain events.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Notification types: `payment_batch.review_requested` and
|
||||
`payment_batch.execution_requested`.
|
||||
- Both use `targetType=payment_batch`, `targetId=<batch id>` and
|
||||
`targetPath=/payment-batches?id=<batch id>`.
|
||||
- Both resolve to the existing `reimbursement` IM domain while payment batches
|
||||
contain only reimbursement entities.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Batch creation resolves exactly one active non-maker reviewer whose effective
|
||||
role permissions include `payment.batch.review`: choose the first stable
|
||||
`ceo` candidate, otherwise the first stable `manager` candidate. Candidate
|
||||
ordering uses a deterministic business key plus `id`; never hard-code a
|
||||
person's name or employee ID and never broadcast to every manager/CEO.
|
||||
- Review completion first targets the active maker when that employee still has
|
||||
`payment.batch.manage`. If unavailable, fall back to other active
|
||||
`finance|finance_mgr` employees with that permission; always exclude the
|
||||
reviewer.
|
||||
- Create station notification records inside the same transaction as the batch
|
||||
creation/review state change. Dispatch through the shared IM dispatcher only
|
||||
after commit. A dispatch failure is logged and, once a channel attempt starts,
|
||||
retained as delivery evidence; it never rolls back the committed payment
|
||||
action.
|
||||
- Payment execution keeps the existing `reimbursement.paid` result notification
|
||||
to the owner/submitter. `付款完成` remains a finance action and must not be
|
||||
represented as another approval.
|
||||
- PC and mobile approval histories use the shared
|
||||
`client/src/lib/approvalDisplay.ts#workflowActionLabel`; `start|submit` both
|
||||
render as `提交`, and pages must not keep local label maps.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- No eligible non-maker reviewer -> `409 VALID_001` with
|
||||
`details.review=no_eligible_reviewer` before batch, item, audit or notification
|
||||
writes. Do not weaken maker-checker, notify the maker or leave an ownerless
|
||||
batch for later diagnosis.
|
||||
- Maker inactive or missing `payment.batch.manage` after review -> notify the
|
||||
eligible finance fallback; no eligible fallback -> review remains committed
|
||||
with zero execution notifications.
|
||||
- Maker attempts review or a reviewed batch is reviewed again -> existing
|
||||
deterministic `409 VALID_001`; zero responsibility notifications and zero IM
|
||||
dispatch.
|
||||
- IM disabled, recipient push disabled/unbound, or no matching channel -> station
|
||||
notification remains; dispatcher returns a fixed skipped reason without
|
||||
rolling back the payment action.
|
||||
- Notification record creation fails inside the transaction -> the protected
|
||||
payment state change fails atomically; no partial handoff is reported.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: finance creates one weekly batch, the eligible non-maker reviewer gets
|
||||
one notification, then successful review returns the action to the active
|
||||
maker for payment and receipt registration.
|
||||
- Base: one active CEO and several managers qualify; only the CEO selected by
|
||||
stable ordering receives the review notification.
|
||||
- Bad: no eligible reviewer is configured but an ownerless batch is still
|
||||
created, leaving finance with an action that nobody can complete.
|
||||
- Bad: notify every finance/manager account, let the maker review their own
|
||||
batch, send before transaction commit, or label payment completion as an
|
||||
approval.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests assert CEO-first single-recipient resolution, manager fallback,
|
||||
maker exclusion, active maker preference after review, inactive-maker finance
|
||||
fallback and reviewer exclusion.
|
||||
- The no-reviewer route test asserts `409` and zero batch, item, audit,
|
||||
notification and dispatch side effects.
|
||||
- Negative tests assert maker conflict, repeated review and duplicate batch
|
||||
creation produce no notification/dispatch.
|
||||
- Dispatcher tests assert both types are IM-supported and resolve to the
|
||||
reimbursement domain; dispatch failure must leave the committed batch state.
|
||||
- PC/mobile E2E asserts `start` renders `提交` from the shared label map and the
|
||||
five-stage employee projection ends with `付款复核` / `付款完成(非审批)`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
notifyEmployeesByRole(["manager", "finance"]);
|
||||
await tx.paymentBatch.update({ data: { status: "reviewed" } });
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const recipients = await resolvePaymentExecutionRecipientIds(tx, {
|
||||
makerId: batch.created_by,
|
||||
reviewerId: actor.id,
|
||||
});
|
||||
const notifications = await createNotificationsWithRecords(tx, {
|
||||
recipientEmployeeIds: recipients,
|
||||
type: NOTIFICATION_TYPES.paymentBatchExecutionRequested,
|
||||
targetType: "payment_batch",
|
||||
targetId: batch.id,
|
||||
});
|
||||
// after commit
|
||||
await flushNotificationDispatchTasks({ tasks: [buildDispatchTask(notifications)] });
|
||||
```
|
||||
|
||||
## Scenario: Reimbursement Payment Batch Create-As-Submit Lifecycle
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing reimbursement payment-batch creation, user-facing status or
|
||||
actions, payment-date defaults, payment-channel compatibility, or receipt
|
||||
prerequisites across server API and React clients.
|
||||
- This lifecycle records an offline bank payment. OA never initiates a bank
|
||||
transfer and `execute` means registering a completed payment.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Create: `POST /api/payment-batches` with
|
||||
`{ entityType: "reimbursement", entityIds: UUID[], name?,
|
||||
defaultPaymentDate?, defaultPaymentChannel?, voucherPrefix? }`.
|
||||
- Review: `POST /api/payment-batches/:id/review`.
|
||||
- Register completed payment: `POST /api/payment-batches/:id/execute`.
|
||||
- Payment metadata: `PATCH /api/payment-batches/:id/items/:itemId` accepts
|
||||
only `paymentVoucherNoOverride?` and `paymentDateOverride?`.
|
||||
- Receipt API: `GET|POST /api/payment-batches/:id/receipt-attachments`.
|
||||
- Receipt void API:
|
||||
`POST /api/payment-batches/:id/receipt-attachments/:attachmentId/void`.
|
||||
- Internal status machine:
|
||||
`draft -> reviewed -> executed|failed|cancelled`; there is no separate submit
|
||||
endpoint or submitted database status.
|
||||
- Shared client label API:
|
||||
`paymentBatchStatusLabel(status)` from
|
||||
`client/src/lib/paymentBatchDisplay.ts`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The normal reimbursement UI sends `entityType`, selected `entityIds` and
|
||||
`defaultPaymentChannel`; the channel defaults to `bank` and becomes an
|
||||
immutable review fact after creation. Item PATCH must not accept a channel
|
||||
override. A persisted legacy item override remains readable/executable for
|
||||
compatibility but is never exposed as a new write path.
|
||||
- Missing name becomes `<company-local YYYY-MM-DD> 付款批次`; the server creates
|
||||
the unique batch number. Missing channel becomes `bank`. Missing actual
|
||||
payment date is resolved to the company-local registration date only when
|
||||
payment completion is registered, not when the batch is created.
|
||||
- Creation is create-as-submit: the same transaction locks candidates, checks
|
||||
reviewer eligibility, creates the batch/items/audit and station notification,
|
||||
then the dispatcher runs after commit. Internal `draft` therefore means
|
||||
`待付款复核`, never an editable unsubmitted draft.
|
||||
- User-facing status labels are `draft=待付款复核`,
|
||||
`reviewed=待财务付款`, `executed=已付款`,
|
||||
`failed=付款登记失败`, `cancelled=已取消`. Payment-batch list/detail and AP
|
||||
views import the shared label function instead of keeping local maps.
|
||||
- The reviewer action is `复核通过`. The finance action is
|
||||
`登记付款完成`; user-facing text must not say OA executes a transfer.
|
||||
- For a reimbursement batch, a persisted null batch channel is interpreted as
|
||||
effective `bank` in API projection, item channel resolution, receipt guard and
|
||||
reimbursement payment record. This compatibility rule must not rewrite the
|
||||
historical batch row.
|
||||
- When an executable bank item exists, a successful receipt query with zero
|
||||
active receipts exposes `上传银行回单` as the sole primary action. Only after
|
||||
an active receipt exists may the UI expose `登记付款完成`. Loading or failed
|
||||
receipt queries expose neither action rather than guessing that the
|
||||
prerequisite is satisfied.
|
||||
- Stage editing is fixed: `draft` is read-only apart from cancel/review;
|
||||
`reviewed|failed` permits voucher/date and receipt upload/void;
|
||||
`executed|cancelled` is entirely read-only. Authorized receipt list/download
|
||||
remains available in every state.
|
||||
- Payment-batch responses never expose stored `snapshot_json` or a
|
||||
`snapshotJson` DTO field. Detail responses rebuild an allowlisted review DTO
|
||||
with only `payeeName`, `bankName` and a format-validated
|
||||
`bankAccountMasked`; list responses keep those detail-only fields null and do
|
||||
not decrypt owner accounts. New items persist the same low-sensitivity
|
||||
`payee` object in `snapshot_json`; full bank accounts and encrypted source
|
||||
values never enter new batch snapshots or any response.
|
||||
- A legacy or malformed stored payee is not trusted merely because its field is
|
||||
named `bankAccountMasked`. Invalid text, ciphertext-looking values and account
|
||||
values without a valid mask cause a read-only fallback to the current
|
||||
reimbursement owner. The fallback must not backfill history, and an existing
|
||||
but unreadable encrypted account fails closed rather than returning the raw
|
||||
stored value.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- No selected reimbursement -> schema validation error; zero writes.
|
||||
- Candidate already reserved or not payable -> existing deterministic `409`;
|
||||
zero new batch.
|
||||
- No eligible non-maker reviewer -> `409 VALID_001` with
|
||||
`details.review=no_eligible_reviewer`; zero batch/item/audit/notification.
|
||||
- Maker reviews own batch -> `409 VALID_001` with maker-checker conflict.
|
||||
- Register bank payment without active receipt -> `409 VALID_001` with
|
||||
`details.receiptAttachments=bank_receipt_required`; source reimbursement and
|
||||
batch status remain unchanged.
|
||||
- Receipt query loading/error -> no premature registration button; the error is
|
||||
shown independently and the user may retry.
|
||||
- Existing null-channel reimbursement batch -> project as bank and apply the
|
||||
receipt guard without updating `default_payment_channel`.
|
||||
- Item PATCH in `draft|executed|cancelled` -> `409 VALID_001` with
|
||||
`details.status=payment_metadata_not_editable`; zero item/audit writes.
|
||||
- Item PATCH contains `paymentChannelOverride` -> strict schema rejection;
|
||||
zero writes.
|
||||
- Receipt upload/void outside `reviewed|failed` -> `409 VALID_001` with
|
||||
`details.status=receipt_attachment_not_editable`; zero durable storage,
|
||||
attachment or audit side effects.
|
||||
- Receipt upload starts in `reviewed|failed` but the locked batch becomes
|
||||
`executed|cancelled` before DB write -> the transaction rejects and every
|
||||
object already written to storage is deleted by compensation; no attachment
|
||||
or audit row remains.
|
||||
- Missing legacy payee source -> return null display fields. Encryption-key or
|
||||
ciphertext authentication failure -> fail closed; never substitute a full
|
||||
account, ciphertext or unrelated employee.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: finance selects approved reimbursements and clicks
|
||||
`创建并提交复核`; the generated batch immediately shows `待付款复核` and one
|
||||
eligible non-maker receives the task notification.
|
||||
- Base: after review, finance completes the bank transfer outside OA, uploads
|
||||
the receipt, then registers payment completion using the company-local date.
|
||||
- Good: reviewer sees the payee name, bank and masked account while every
|
||||
payment-registration control and receipt mutation is absent in `draft`.
|
||||
- Bad: label `draft` as `草稿`, ask the user to find a nonexistent submit button,
|
||||
show `登记付款完成` before receipt status is known, or silently rewrite a
|
||||
legacy null channel in production.
|
||||
- Bad: permit voucher/channel/receipt changes while a reviewer is checking the
|
||||
batch, trust a persisted field name as proof of masking, or return
|
||||
`snapshotJson`, `bank_account_encrypted`, ciphertext or a full account.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests assert optional name, generated company-local title, bank default,
|
||||
payment-date default, CEO-first reviewer, no-reviewer zero side effects and
|
||||
null-channel compatibility without mutation.
|
||||
- PostgreSQL concurrency regression asserts double create, double execute,
|
||||
execute/cancel and direct-pay/batch races remain mutually exclusive.
|
||||
- Route tests assert the full state matrix, channel strict rejection, zero
|
||||
mutation side effects, masked new snapshots, legacy fallback and malicious
|
||||
stored snapshots without historical writes or full-account/ciphertext
|
||||
response values. They also assert list requests do not perform detail-level
|
||||
payee lookup/decryption.
|
||||
- Receipt tests cover both `reviewed` and `failed` positive paths, all three
|
||||
read-only states, zero stored files on immediate rejection, and storage
|
||||
compensation when the locked status changes after upload parsing.
|
||||
- Client E2E asserts creation sends the selected `defaultPaymentChannel`, all
|
||||
consumers use the shared labels, `draft` has no payment/receipt editors,
|
||||
`executed|cancelled` remains read-only, and receipt upload switches the sole
|
||||
primary action from `上传银行回单` to `登记付款完成`.
|
||||
- Build/typecheck, Prisma validation and focused payment-batch route/export tests
|
||||
are required before commit.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```tsx
|
||||
const label = status === "draft" ? "草稿" : status;
|
||||
<ChannelSelect onChange={(value) => patchItem({ paymentChannelOverride: value })} />
|
||||
<Button onClick={execute}>执行付款</Button>
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```tsx
|
||||
<StatusBadge label={paymentBatchStatusLabel(batch.status)} />
|
||||
{batch.status === "draft" ? <PayeeReviewFacts item={item} /> : null}
|
||||
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
|
||||
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
|
||||
```
|
||||
|
||||
## Scenario: Entity-Specific Return And Reject Actions
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing approval action availability, labels or layouts for reimbursement, leave, attendance correction, purchase or travel tasks.
|
||||
- Action availability follows the business entity's real edit/resubmit consequence; do not infer one global meaning from workflow outcome names alone.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Capability source: `server/src/routes/workflow.js#listAllowedActionsForTask`.
|
||||
- Reimbursement UI: `client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx`.
|
||||
- Legacy audit values remain `workflow_history.action=return|reject` and task outcome `returned|rejected`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- New reimbursement tasks advertise `approve + return` and optional permitted `transfer`; they do not advertise `reject`. The UI labels return as `退回修改`, writes the existing returned history and returns the same reimbursement to `draft` for editing and resubmission.
|
||||
- Historical rejected reimbursements remain readable and editable under the existing compatibility rule. Never rewrite them to returned or remove reject mappings from history, print, processing progress or notifications.
|
||||
- Leave, purchase and travel retain both return and reject until their module semantics are separately changed. Attendance correction retains reject only. Do not globally remove reject from fixtures or shared confirmation copy.
|
||||
- Purchase/travel return restores `draft`; reject leaves the entity `rejected`, which is not editable or submittable in place. This is a real consequence difference.
|
||||
- The legacy reimbursement reject endpoints may remain callable during compatibility rollout even though new `allowedActions` and product UI do not expose them. Endpoint retirement requires a separate caller/compatibility audit.
|
||||
- Reimbursement fail-closed conditions, permissions, B1/B2 checks and return transaction stay unchanged when action presentation changes.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Reimbursement pending + approve permission -> `approve,return` plus optional transfer; reject absent.
|
||||
- Leave/purchase/travel pending + matching permission -> `approve,reject,return` plus optional transfer.
|
||||
- Attendance pending + matching permission -> `approve,reject` plus optional transfer; return absent.
|
||||
- Processed, non-actionable or unauthorized task -> no actions.
|
||||
- Historical reimbursement status rejected -> keep `已驳回`, edit/resubmit compatibility and original audit history.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: finance sees one clear modification outcome, `退回修改`, and the applicant edits the same reimbursement before a new workflow instance starts.
|
||||
- Base: purchase approval keeps both return and reject because only return permits in-place correction.
|
||||
- Bad: hide reject only with CSS while the API still advertises it, rename reject to return while calling the reject endpoint, or erase historical rejected labels because new reimbursement tasks no longer produce them.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Table-driven unit coverage for the five entity action matrices, transfer permission, processed tasks and unauthorized actors.
|
||||
- Reimbursement PC/mobile coverage for no visible reject, return progressive disclosure, fail-closed disabled actions and a two-action responsive group.
|
||||
- Regression coverage proving the four non-reimbursement action sets and shared panel DOM remain unchanged.
|
||||
- Compatibility coverage for historical rejected reimbursement display/edit/resubmit and legacy reject endpoint behavior.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
API allowedActions = approve,reject,return
|
||||
reimbursement UI hides reject locally
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
API reimbursement allowedActions = approve,return[,transfer]
|
||||
reimbursement UI renders only declared capabilities
|
||||
legacy reject endpoint and rejected history remain compatible but undiscoverable from the new UI
|
||||
```
|
||||
|
||||
## Scenario: Shared Payment Processing Projection
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding or changing payment-stage labels, status projection, receipt/order guidance, reimbursement progress or payment-batch detail progress.
|
||||
- This is a read-only cross-layer projection. It does not add workflow tasks, alter the payment write state machine or relax receipt/maker-checker gates.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Shared service: `server/src/services/paymentProcessingProgress.js`.
|
||||
- Builder: `buildPaymentProcessingSemantics({ status, createdAt, reviewedAt, executedAt, cancelledAt, cancelledReason, requiresBankReceipt, receiptCount, paymentOrderExportCount })`.
|
||||
- Channel helpers: `resolveItemPaymentChannel(batch, item)` and `requiresBankReceipt(batch)`.
|
||||
- Payment detail: `GET /api/payment-batches/:id` returns `processingProgress.stages[]`; list responses do not return this field.
|
||||
- Stage states: `completed | current | pending | exception | terminated`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The shared service is the only runtime source for payment stage keys, labels and `draft|reviewed|executed|failed|cancelled` state projection. Reimbursement and payment-batch routes only assemble their own DTO view, resolve actor names and add permission-gated links.
|
||||
- Reimbursement keeps its single-item `paymentBatchItem` view; payment detail keeps the whole-batch view. Do not replace these with one database-aware DTO builder, and do not add route-local status branches after calling the shared builder.
|
||||
- `requiresBankReceipt` is shared with the execute guard and resolves the effective item channel, including the legacy null-channel reimbursement fallback to `bank`. Receipt count alone must never decide whether a receipt is required.
|
||||
- A reviewed non-bank batch is current at `付款完成` with guidance to register completion. It must not mention uploading a bank receipt or generating a bank order.
|
||||
- A reviewed bank batch with no active receipt distinguishes no order vs order generated; an active receipt changes guidance to registration. Payment-order count affects copy only when `requiresBankReceipt=true`.
|
||||
- `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed.
|
||||
- `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.
|
||||
- When an authorized reimbursement has entered a batch, its processing progress exposes at most one batch link. `draft` and cancellation before review attach it to `付款复核`; `reviewed|executed|failed` and cancellation after review attach it to `付款完成`. Unauthorized actors receive neither link.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Unknown/missing status -> created remains completed and downstream stages remain pending; do not guess a current or completed business fact.
|
||||
- `reviewed + requiresBankReceipt=false` -> current payment guidance is registration only, regardless of receipt/order counts.
|
||||
- `reviewed + requiresBankReceipt=true + receiptCount=0 + exportCount=0` -> guide offline payment and receipt upload.
|
||||
- Same with `exportCount>0` -> say the bank order was generated and wait for payment/receipt; do not imply money moved.
|
||||
- `reviewed + requiresBankReceipt=true + receiptCount>0` -> guide registration completion.
|
||||
- `cancelled + reviewedAt=null` -> review `exception`, payment `terminated`; `cancelled + reviewedAt!=null` -> review `completed`, payment `exception`.
|
||||
- Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: both pages import the same semantic builder, while the reimbursement route selects two payment stages and the payment route returns all three.
|
||||
- Base: a cash batch without a receipt shows `等待财务登记付款完成` and exposes the permitted registration action.
|
||||
- Bad: a route adds `if (batch.status === "reviewed")` to rewrite stage state/detail, or a client infers receipt requirements from `receiptCount===0`.
|
||||
- Bad: a cancelled batch shows a later `待处理` node, or a cash batch tells finance to upload a bank receipt.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Pure table-driven unit tests cover all five statuses, both cancellation positions, bank/non-bank channels, order count, receipt count and actorSource.
|
||||
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions.
|
||||
- Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
|
||||
- Reimbursement route tests assert the five batch statuses, both cancellation positions and the no-permission case never produce duplicate batch links.
|
||||
- PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching.
|
||||
- Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
if (batch.status === "reviewed" && receiptCount === 0) {
|
||||
paymentStage.detail = "请上传银行回单";
|
||||
}
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const semantics = buildPaymentProcessingSemantics({
|
||||
status: batch.status,
|
||||
requiresBankReceipt: requiresBankReceipt(batch),
|
||||
receiptCount: batch._count?.receipt_attachments ?? 0,
|
||||
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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
|
||||
@@ -462,3 +514,175 @@ const finalSummary = summarizeFinalItemStatuses(finalItemStatuses);
|
||||
batch.execution_summary_json = finalSummary;
|
||||
batch.status = finalSummary.failedCount > 0 ? "failed" : "executed";
|
||||
```
|
||||
|
||||
## Scenario: PostgreSQL Advisory-Lock Namespaces
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: allocating an application-level sequence or serializing a small
|
||||
cross-row critical section that cannot be protected by locking one existing
|
||||
row.
|
||||
- Use this only for PostgreSQL-backed paths whose correctness is also verified
|
||||
against a disposable real database; do not introduce advisory locks for
|
||||
ordinary single-row updates already covered by `FOR UPDATE`.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Shared registry: `server/src/constants/advisoryLocks.js`.
|
||||
- Lock shape: `pg_advisory_xact_lock(namespace_int4, object_key_int4)`.
|
||||
- Registered namespace: `paymentBatchDailySequence = 1001`; its object key is
|
||||
the company-local `YYYYMMDD` integer.
|
||||
- Prisma call for a void-returning lock statement: `tx.$executeRaw`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Register every new first-argument namespace in the shared constant file;
|
||||
never reuse an existing namespace for an unrelated critical section.
|
||||
- Use the two-`int4` form with an explicit, inspectable object key. Do not use
|
||||
`hashtext` or an undocumented magic pair at the call site.
|
||||
- Acquire the transaction-level lock before reading the protected maximum or
|
||||
current state. Lock acquisition, protected read and write must stay in the
|
||||
same Prisma interactive transaction so commit or rollback releases the lock.
|
||||
- Keep one stable lock order when a transaction needs more than one lock.
|
||||
- A PostgreSQL uniqueness error aborts the transaction. Do not catch `P2002`
|
||||
and retry inside the same interactive transaction; any fallback must retry
|
||||
the complete transaction from the beginning.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- `$queryRaw` reads `SELECT pg_advisory_xact_lock(...)` -> Prisma driver may
|
||||
fail with `P2010` because PostgreSQL returns a `void` column.
|
||||
- `$executeRaw` executes the same `SELECT` -> lock is acquired without result
|
||||
deserialization; continue to the protected query.
|
||||
- Namespace or object key outside signed `int4` -> fail before business writes;
|
||||
correct the registry/key design rather than silently casting or hashing.
|
||||
- Protected daily sequence exhausted -> stable `409 VALID_001` and rollback;
|
||||
do not widen or randomize the public identifier format at the boundary.
|
||||
- Concurrent callers with the same pair -> one waits, then reads the committed
|
||||
maximum and allocates the next value.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: two simultaneous payment-batch creations lock `(1001, YYYYMMDD)`, then
|
||||
commit distinct consecutive identifiers.
|
||||
- Base: different company-local dates use different object keys and do not
|
||||
contend.
|
||||
- Bad: query the maximum before taking the lock, use a process mutex, or retry a
|
||||
unique violation inside the already-aborted transaction.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Unit test the exact call order and error boundary with mocked
|
||||
`$executeRaw`/`$queryRaw`; assert no create, item or audit write on exhaustion.
|
||||
- Run `bash scripts/qa/run-web-gates.sh payment-concurrency-local` against a
|
||||
disposable PostgreSQL database. The daily-sequence race must create two
|
||||
different sources concurrently and assert two consecutive unique numbers.
|
||||
- Run `node --check` on the route, namespace registry and concurrency script,
|
||||
plus Prisma validate and a read-only review for affected payment paths.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${namespace}, ${dateKey})`;
|
||||
const latest = await readLatestSequence(tx);
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
await tx.$executeRaw`
|
||||
SELECT pg_advisory_xact_lock(${namespace}::int, ${dateKey}::int)
|
||||
`;
|
||||
const latest = await readLatestSequence(tx);
|
||||
```
|
||||
|
||||
## Scenario: Terminal Contract State and Required Audit
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding a terminal state or irreversible action to a Prisma-backed
|
||||
business ledger, especially when the state needs explanatory fields and an
|
||||
audit trail.
|
||||
- Applies to `ContractRegistry` termination, archive corrections, all import
|
||||
or batch writers, and any future endpoint that can mutate the same row.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- `POST /api/contracts-registry/:id/terminate` with `{ reason }` where the
|
||||
trimmed reason is 10–1000 characters.
|
||||
- `POST /api/contracts-registry/:id/corrections` with `{ summary }` for an
|
||||
archived row only.
|
||||
- DB fields: `status`, `terminated_at`, `terminated_reason`,
|
||||
`terminated_by`; the employee foreign key uses `ON DELETE RESTRICT`.
|
||||
- Required audit helper: `writeRequiredAudit(tx, request, action, targetId,
|
||||
extra)`; it must throw on insert failure.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- `status = terminated` iff all three `terminated_*` fields are non-null and
|
||||
non-empty; every other persisted status iff all three are null.
|
||||
- Only the dedicated termination transaction may create `terminated`; general
|
||||
POST/PATCH and CSV/import writers reject it. Existing terminated rows are
|
||||
immutable to import updates, and existing archived rows are immutable to
|
||||
import updates (use the correction API).
|
||||
- Termination, status changes, renewal and correction audit writes stay inside
|
||||
the same transaction as the protected write. A correction claims the current
|
||||
`status + updated_at` version before appending its audit record.
|
||||
- List DTOs use a minimal mapper; terminal reason, operator and timestamp are
|
||||
detail-only fields.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Partial termination fields or a terminal row with missing fields -> stable
|
||||
`500 SYS_001`; do not serialize an inconsistent record.
|
||||
- General write with `terminated`, terminal edit/renew/archive, or a stale
|
||||
correction version -> `409 VALID_001` and zero business/audit side effects.
|
||||
- Required audit insert failure -> transaction rollback and `5xx`; never use a
|
||||
swallowing best-effort helper for an irreversible/status-changing operation.
|
||||
- Import status `terminated`/`expired`, a status transition, or update of an
|
||||
existing terminated/archived row -> recorded validation error; no upsert.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: termination writes status, three fields and audit atomically; a failed
|
||||
audit leaves the source row active and the audit table unchanged.
|
||||
- Base: an archived correction updates only the version timestamp and appends
|
||||
a safe audit summary; a concurrent restore loses the optimistic guard and
|
||||
returns `409`.
|
||||
- Bad: write status first, call a best-effort audit afterward, or let a CSV
|
||||
upsert set `terminated` without the three fields and dedicated audit.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Focused route tests for complete/partial invariants, terminal filters and
|
||||
effective status, general-write rejection, required-audit rollback,
|
||||
renewal rollback, correction version conflicts, and safe list/detail DTOs.
|
||||
- Unit test every non-HTTP import status guard and existing-terminal refusal.
|
||||
- Prisma validate, migration status, `node --check` on touched scripts/routes,
|
||||
focused Vitest, and Playwright coverage for desktop/mobile action boundaries.
|
||||
- Read-only reviewer must inspect all write paths, including scripts and
|
||||
exports, after implementation.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
await prisma.contractRegistry.update({ where: { id }, data: { status: "terminated" } });
|
||||
await writeAudit(prisma, request, "CONTRACT_REGISTRY_TERMINATED", id); // swallows errors
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const current = await readAndValidateContract(tx, id);
|
||||
await tx.contractRegistry.updateMany({
|
||||
where: { id, status: current.status, updated_at: current.updated_at },
|
||||
data: buildTerminalFields(request, reason),
|
||||
});
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
|
||||
});
|
||||
```
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
文件差异内容过多而无法显示
加载差异
@@ -76,6 +76,45 @@ otherwise -> defer, hide, freeze or keep as a manual SOP
|
||||
**Related**: the evidence-backed baseline and initial retirement candidates are
|
||||
recorded in task `07-26-small-team-internal-oa-review`.
|
||||
|
||||
### Design Decision: Finance-Backoffice Scope And Focused Adoption
|
||||
|
||||
**Context**: the 2026-08-11 production read-only census found extremely low
|
||||
team adoption across the system. This does not prove that missing features are
|
||||
the cause; several workflows intentionally live in Enterprise WeChat or manual
|
||||
processes.
|
||||
|
||||
**Decision**: separate product scope from adoption intensity. hyb-oa is the
|
||||
company's finance and low-frequency ledger backoffice, not its general daily
|
||||
collaboration suite. Reimbursement, invoices/OCR, approval, payment and AP are
|
||||
OA-primary workflows and should be used for real work. Contract, procurement
|
||||
and asset ledgers remain available but frozen until real events occur. HR,
|
||||
attendance and daily collaboration stay Enterprise-WeChat-primary. Project is
|
||||
currently a frozen zero-use domain and must not be disabled when that would
|
||||
degrade an OA-primary finance flow.
|
||||
|
||||
Payroll, performance, ERP/integration, budget and cutover capabilities are
|
||||
retirement-review candidates, not approved removals. Authentication, least
|
||||
privilege, payment maker-checker, audit, backup/restore and leaver revocation
|
||||
remain mandatory regardless of frequency.
|
||||
|
||||
Do not implement technical coercion to manufacture adoption. Requiring the
|
||||
next reimbursement to use OA is a management rule; notification expansion,
|
||||
onboarding guides, channel blocking or reminder machinery need observed user
|
||||
friction before they can become product work. The old R63-R66 expansion
|
||||
schedule is superseded.
|
||||
|
||||
```text
|
||||
OA-primary finance flow + observed blocker -> consider one small product task
|
||||
low-frequency ledger + no real event -> keep available and frozen
|
||||
Enterprise-WeChat-primary or cheaper reliable substitute -> do not duplicate
|
||||
outside the focused scope -> hide, freeze or start a separate retirement review
|
||||
```
|
||||
|
||||
**Related**: task `08-11-internal-oa-usage-census-and-simplification` contains
|
||||
the evidence boundary and module decision matrix. Task
|
||||
`08-11-internal-oa-finance-backoffice-positioning-and-navigation-reduction`
|
||||
records the adopted scope and first reversible navigation reduction.
|
||||
|
||||
## Stop Conditions
|
||||
|
||||
- Production config, deployment, database reset, migration drift, real data
|
||||
@@ -125,6 +164,11 @@ recorded in task `07-26-small-team-internal-oa-review`.
|
||||
`--force` to erase `workflow.md`, `config.yaml`, `.gitignore` or `AGENTS.md`.
|
||||
- Treat dry-run output and manifest cleanliness as evidence, but do not assume
|
||||
a dry-run is write-free; inspect `git status` before and after it.
|
||||
- `trellis update` may copy ignored, local-only platform settings into the new
|
||||
`.trellis/.backup-<timestamp>/`. Keep that exact backup only through the
|
||||
merge/rollback window, then remove it before the sensitive-content gate once
|
||||
Git and the pinned prior CLI provide the rollback path. Do not weaken the
|
||||
scanner and do not blanket-delete older backups outside the task scope.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -134,6 +178,9 @@ recorded in task `07-26-small-team-internal-oa-review`.
|
||||
discovery, and rerun the preview.
|
||||
- `.new` conflict files remain -> upgrade is incomplete; merge or discard each
|
||||
one deliberately.
|
||||
- Sensitive scan reports a complete local path inside the newly created
|
||||
upgrade backup -> resolve the exact backup from this run, remove it after the
|
||||
merge is stable, and rerun the scan; an ignored directory is still scanned.
|
||||
- Post-upgrade dry-run changes manifest again -> investigate template hash drift
|
||||
before commit.
|
||||
- No active task directory after the last task is archived -> valid idle state;
|
||||
@@ -157,8 +204,11 @@ recorded in task `07-26-small-team-internal-oa-review`.
|
||||
- Exercise task validate/current/list and run Trellis governance.
|
||||
- Archive the final active task and assert Trellis governance still passes with
|
||||
zero active task directories.
|
||||
- Confirm no `.new`, backup, bytecode or out-of-scope platform assets are
|
||||
tracked; finish with `git diff --check`.
|
||||
- Record pre-existing backup directory names before update; confirm no `.new`,
|
||||
newly generated backup, bytecode or out-of-scope platform assets are tracked
|
||||
or left for the sensitive scanner. Do not interpret a historical backup as
|
||||
evidence that the current upgrade backup may remain. Finish with
|
||||
`git diff --check`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
@@ -18,6 +18,147 @@
|
||||
redacted form.
|
||||
- `scripts/qa/check-trellis-governance.mjs` must pass before commit.
|
||||
|
||||
## Scenario: Bank Account Snapshots And Payment-Order Export
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: company payment-account maintenance, employee payee-bank maintenance,
|
||||
payment-batch creation, full-account reveal or bank payment-order export.
|
||||
- OA prepares payment evidence and a bank-import file; it does not call a bank
|
||||
payment API or treat the generated file as proof of payment.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- DB: `BankInstitution`, `PaymentAccount`, `Employee.bank_institution_id`,
|
||||
`PaymentBatch.payment_account_snapshot_json`, item `snapshot_json.payee`, and
|
||||
`payment_order_export_*` fields.
|
||||
- Reveal APIs use `POST` with JSON `{ "reason": "2-500 chars" }`; ordinary read
|
||||
APIs return only bank name and masked account.
|
||||
- Payment-order API: `POST /api/payment-batches/:id/payment-order` with a reason;
|
||||
success is an in-memory BIFF8 `.xls` download with `Cache-Control: no-store`.
|
||||
- Runtime: Node passes validated row JSON through stdin to the fixed
|
||||
`fill-payment-order-template.py`; the dedicated Python runtime returns BIFF8
|
||||
bytes on stdout. `PAYMENT_ORDER_PYTHON_BIN` may select the fixed interpreter,
|
||||
but requests cannot select the script or template path.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Employee account editing remains HR/admin-owned. Never grant
|
||||
`employee.bank.manage` to finance, finance manager or system administrator.
|
||||
- Company account editing is finance-manager/admin-owned; ordinary finance is
|
||||
masked read plus reason-audited full reveal.
|
||||
- A new bank batch freezes the exact existing ciphertext for payer and payees.
|
||||
Copy ciphertext directly; decrypt only to calculate the masked suffix. Never
|
||||
decrypt and re-encrypt while building a snapshot.
|
||||
- Review UI reads masked values from the frozen snapshot. Finance manual-payment
|
||||
reveal also reads the frozen item snapshot, not the employee's current bank
|
||||
profile. An employee profile change after review must not change the payment
|
||||
target for that batch.
|
||||
- Ordinary batch, AP and export DTOs must explicitly select safe fields and must
|
||||
never return raw snapshot JSON, `v1:` ciphertext or a full account.
|
||||
- Payment-order preflight returns a safe blocker array containing fixed codes
|
||||
and, when useful, the reimbursement business number. Collect every batch and
|
||||
item blocker before returning; do not restore fail-fast "fix one, find one"
|
||||
behavior. The UI maps those fixed codes to natural Chinese and keeps manual
|
||||
payment visible for legacy or unsupported batches.
|
||||
- Legacy batches without ciphertext snapshots may still complete through manual
|
||||
payment plus receipt, but they can neither export a payment order nor reveal a
|
||||
supposed frozen account through a live-profile fallback.
|
||||
- A payment-order file is an efficiency artifact. A bank receipt remains the
|
||||
hard evidence required before registering bank payment complete.
|
||||
- Generate bank files by filling the controlled, blank BIFF8 template asset.
|
||||
Rebuilding a workbook with matching headers is forbidden when it loses the
|
||||
bank template's column widths, row heights, fonts, fills, borders or
|
||||
alignment. The asset contains headers and formatted blank rows only, and its
|
||||
SHA-256 is verified before every fill.
|
||||
- BIFF8 rich-text runs are part of the template contract. Open the template
|
||||
with `formatting_info=True`, copy every source font property, and explicitly
|
||||
call `write_rich_text`; `xlutils.filter` does not preserve those runs by
|
||||
default. Never replace this with a hard-coded "red" font approximation.
|
||||
- Node owns authorization, snapshot decryption, blockers, ordering, serials,
|
||||
amounts, audit and export counters. Python only copies the fixed template and
|
||||
fills the already validated 15-cell rows. Full accounts travel only on child
|
||||
stdin; never place them in argv, environment variables, logs, stderr or a
|
||||
temporary workbook file.
|
||||
- CI and the production image install `xlrd`, `xlwt` and `xlutils` from the same
|
||||
`--require-hashes` requirements. Do not fall back to a style-dropping
|
||||
generator when Python, a dependency or the template is unavailable.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing/mixed legal entity, no unique active payer, missing bank institution,
|
||||
missing account or decrypt failure -> reject batch creation with `409` and no
|
||||
batch/item writes.
|
||||
- First release bank code is not `CCB` for any payer/payee -> reject the whole
|
||||
export; do not emit `02`, skip a row or create a partial file.
|
||||
- Batch not `reviewed`, legacy snapshot or malformed snapshot -> reject export
|
||||
with `409`; do not increment export count or send file bytes.
|
||||
- Reveal without permission or approved finance business role -> `403`.
|
||||
- Reveal/export without a 2-500 character reason -> `400 VALID_001` and no full
|
||||
account/file response.
|
||||
- Successful export audit/update failure -> no download response.
|
||||
- Template hash/structure mismatch -> `409 PAYMENT_ORDER_EXPORT_BLOCKED`, no
|
||||
export increment and no file bytes; manual payment plus receipt remains
|
||||
available.
|
||||
- Python executable/dependency missing, timeout, oversized output, non-zero exit
|
||||
or invalid OLE magic -> fixed safe generation error; never copy child stderr
|
||||
into the API error or application log.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: review shows a masked frozen CCB account; finance supplies a reason and
|
||||
temporarily reveals that same frozen account for manual transfer.
|
||||
- Base: a historical batch displays compatibility metadata, proceeds by manual
|
||||
payment and receipt, and clearly has no payment-order capability.
|
||||
- Bad: export or manual-transfer reveal re-queries the employee profile after
|
||||
review, silently uses a changed account, or returns raw snapshot JSON.
|
||||
- Good: a fourth synthetic row copies the first template data-row style and
|
||||
height while the amount cell changes only to numeric `0.00` formatting.
|
||||
- Bad: create a fresh workbook from the 15 labels, or invoke Python with an
|
||||
account in argv/env, even if the resulting values look correct in a parser.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Permission matrix for employee/company account manage, masked read and full
|
||||
reveal; include an explicit system-admin denial.
|
||||
- Privacy-negative assertions over batch/list/detail/AP responses for raw
|
||||
accounts, `v1:` and raw snapshots.
|
||||
- Batch creation tests for single legal entity, direct ciphertext copy,
|
||||
CCB-only, decrypt failure and legacy behavior.
|
||||
- BIFF8 template-fill tests plus independent readback for three sheets, 15
|
||||
headers, leading-zero `01`, long account text, Chinese, numeric `0.00`
|
||||
amounts and deterministic row order. Compare visual signatures for column
|
||||
widths, row heights, font/bold, fill, border, alignment and wrapped text;
|
||||
include a row beyond the three preformatted template rows.
|
||||
- Compare rich-text segments semantically, including start offsets and complete
|
||||
font properties. Required headers must assert the first `*` uses the source
|
||||
red font and all remaining text uses the source black font; whole-cell style
|
||||
equality is insufficient.
|
||||
- Negative tests cover missing Python/dependencies, template hash drift, child
|
||||
failure and a child that echoes the synthetic input to stderr; exposed errors
|
||||
and logs must not contain the account or original row payload.
|
||||
- Build one synthetic batch with at least two independent payer/payee blockers;
|
||||
assert the DTO/error contains both safe blocker entries and emits no file,
|
||||
export counter or success audit.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
const liveEmployee = await prisma.employee.findUnique({ where: { id: ownerId } });
|
||||
return decryptStringOrThrow(config, liveEmployee.bank_account_encrypted);
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const encrypted = paymentBatchItem.snapshot_json?.payee?.bankAccountEncrypted;
|
||||
assertReviewedFinanceReveal({ batch, actor, reason, encrypted });
|
||||
await writeRevealAudit();
|
||||
return decryptStringOrThrow(config, encrypted);
|
||||
```
|
||||
|
||||
## Scenario: Defensive Security Finding Summaries
|
||||
|
||||
### 1. Scope / Trigger
|
||||
@@ -134,7 +275,12 @@
|
||||
`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
|
||||
query-grant 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. Payment-batch receipt preview is a separate,
|
||||
authenticated domain route described below; it never uses a bearer query URL.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
@@ -163,9 +309,21 @@
|
||||
- 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.
|
||||
- The only ordinary-browser inline exception is
|
||||
`GET /api/payment-batches/:id/receipt-attachments/:attachmentId/file?mode=preview`.
|
||||
It remains batch- and department-authorized, revalidates bounded actual bytes,
|
||||
accepts only PDF/JPEG/PNG/WebP, writes `PAYMENT_BATCH_RECEIPT_PREVIEWED` with
|
||||
fixed reason `付款批次内在线预览` before sending bytes, and returns the validated
|
||||
MIME with `inline`, `nosniff`, private no-store, no-referrer, sandbox CSP and
|
||||
same-origin resource policy. Default/download mode keeps forced binary download
|
||||
and reason-required audit behavior. Do not generalize this exception into a
|
||||
reusable file-center preview route.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
@@ -183,6 +341,8 @@
|
||||
- Explicitly quarantined, missing, oversized, corrupt or unverifiable stored
|
||||
object -> do not return file bytes; write a fixed blocked-reason audit where
|
||||
possible.
|
||||
- Payment receipt preview with a MIME outside PDF/JPEG/PNG/WebP -> blocked audit
|
||||
with fixed reason code; no inline bytes.
|
||||
- Successful-download audit write fails -> return 5xx before download headers
|
||||
or file bytes.
|
||||
|
||||
@@ -190,6 +350,8 @@
|
||||
|
||||
- Good: a bank receipt upload returns `confidential`, `receipt`,
|
||||
`reason_required`; download first asks for用途 and writes an audit log.
|
||||
- Good: an authorized payment-detail preview uses the fixed server purpose and
|
||||
writes its preview audit; downloading the same receipt still asks for用途.
|
||||
- Base: a reimbursement supporting material upload defaults to `internal`,
|
||||
`supporting_material`, `standard` and remains downloadable by authorized
|
||||
participants.
|
||||
@@ -217,13 +379,17 @@
|
||||
before storage/DB writes, current authentication and object authorization,
|
||||
historical revalidation, blocked-audit reasons, audit-before-send and the
|
||||
complete forced-download header contract.
|
||||
- Payment receipt tests additionally assert preview MIME/disposition/security
|
||||
headers, fixed-purpose audit-before-send, PDF/image allowlist, blocked files,
|
||||
and that default download still rejects a missing reason.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -41,6 +41,207 @@ or administration workflow has already migrated.
|
||||
rule, applicant-visible mobile submission page and actual post-submission
|
||||
approval chain; API template metadata alone is insufficient.
|
||||
|
||||
## Scenario: Native File Preview With Narrow Short-Lived Grants
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: an authenticated OA page inside the Enterprise WeChat self-built app
|
||||
must preview a reimbursement invoice or supporting file with the official
|
||||
`previewFile` JSAPI.
|
||||
- Purpose: WeCom's native downloader does not reliably carry the WebView cookie
|
||||
or OA bearer header, while ordinary PDF links may download instead of preview.
|
||||
This is a narrow native-preview bridge, not a general query-token download
|
||||
design.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- JS-SDK signature: `POST /api/auth/wecom/js-sdk-signature`, authenticated body
|
||||
`{ url: string }`; response `{ corpId, timestamp, nonceStr, signature }`.
|
||||
- Invoice grant: `POST /api/invoice-pool/:id/signed-url`; response includes
|
||||
`{ signedUrl, expiresAt, originalName, mimeType, fileSize }`.
|
||||
- Reimbursement attachment grant:
|
||||
`POST /api/reimbursements/:id/attachments/:attachmentId/preview-url`, optional
|
||||
body `{ reason }`; response uses the same file metadata shape.
|
||||
- Reimbursement material bundle:
|
||||
`POST /api/reimbursements/:id/materials/preview-bundle`, optional body
|
||||
`{ reason }`; response contains one short-lived merged PDF URL plus exact
|
||||
`name`, `size` and `pageCount`.
|
||||
- Native call: `previewFile({ url, name, size })`; `name` includes an extension
|
||||
and `size` is the persisted positive byte count.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Fetch and cache the WeCom JSAPI ticket only on the server. Never return or log
|
||||
the ticket, app access token, Agent Secret or raw provider response.
|
||||
- Sign only the first configured OA `APP_ORIGIN`; reject cross-origin URLs,
|
||||
URL credentials and fragments. The signed page URL excludes the hash.
|
||||
- A native file grant expires in at most 120 seconds and binds purpose, exact
|
||||
business object, exact file object and actor. It is signed only after current
|
||||
authentication, business-read permission and download-reason checks pass.
|
||||
- Invoice access may reuse the existing current-pending-task exception. Do not
|
||||
grant the approver invoice-management, edit, unbind or delete permission.
|
||||
- The native GET writes a successful access audit before sending headers or
|
||||
bytes and returns `private, no-store` plus `no-referrer`.
|
||||
- PC keeps its browser/authenticated download behavior. Only a detected WeCom
|
||||
WebView dynamically loads `@wecom/jssdk` and calls `previewFile`; do not send
|
||||
the actual file through a WeCom message or native approval payload.
|
||||
- Whole-reimbursement continuous preview derives one deterministic PDF: PDF
|
||||
pages are copied and images are embedded as pages, then WeCom receives one
|
||||
`previewFile` call. The derivative is explicitly not an original or digital-
|
||||
signature fact source; individual originals remain separately authorized.
|
||||
- JS-SDK is allowed only on authenticated business pages that need a native
|
||||
capability and is dynamically imported behind WeCom detection. The hosted PC
|
||||
login/password/email-code page remains script-free as required by the
|
||||
`Hosted PC WeCom Web Login` scenario below.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing authentication on signature/grant issue -> `401`.
|
||||
- Cross-origin page URL -> `403`; malformed, credential-bearing or fragment URL
|
||||
-> `400`, before any provider call.
|
||||
- Ticket/token provider failure -> stable `503` detail without provider values.
|
||||
- Missing file metadata, extension or positive integer size -> frontend stops
|
||||
before `previewFile` and shows a stable error.
|
||||
- Missing required download reason -> `400 { reason: "required" }`.
|
||||
- Expired, tampered, wrong-purpose or object-mismatched file grant -> `403`.
|
||||
- Access audit failure -> `5xx` with no file body.
|
||||
- Missing/invalid merged PDF URL, name or size -> frontend stops before the
|
||||
bridge call. Bundle expiry/cache loss -> safe fallback to individually
|
||||
authorized originals; do not broaden token scope or extend it silently.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: a pending reimbursement approver opens a 120-second invoice URL through
|
||||
`previewFile`, sees the original in WeCom and receives no edit capability.
|
||||
- Base: a PC user opens the same invoice through the browser and downloads an
|
||||
attachment through the existing authenticated Blob path.
|
||||
- Base: a WeCom approver requests one authorized reimbursement PDF bundle,
|
||||
then `previewFile` opens it for continuous native scrolling.
|
||||
- Bad: expose a long-lived generic `?token=`, trust a caller-supplied size/name,
|
||||
put a provider ticket in the response, or use async `window.open(blob:)` in
|
||||
WeCom and assume desktop popup behavior applies.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests cover same-origin signing, provider failure/redaction and rejection
|
||||
of cross-origin, credential-bearing and fragment URLs before provider access.
|
||||
- Grant tests assert exact object/actor binding, at-most-120-second expiry,
|
||||
metadata, reason gate, issue audit, access audit, tamper/expiry/mismatch denial
|
||||
and audit-failure fail-closed behavior using synthetic files only.
|
||||
- Playwright injects a synthetic `WeixinJSBridge`, asserts `previewFile` receives
|
||||
the exact URL/name/size for invoice and attachment, and asserts no
|
||||
`window.open` call in WeCom. PC assertions keep browser URL and Blob behavior.
|
||||
- Continuous-preview Playwright asserts one `previewFile` call with exact URL,
|
||||
filename and byte size. Backend tests separately prove deterministic output,
|
||||
exact response size and object/actor/bundle binding.
|
||||
- Production deployment and real-device preview remain separate approvals.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
await downloadWithBearer();
|
||||
window.open(URL.createObjectURL(blob)); // WeCom may block after await
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
authenticated grant issue -> <=120s object-bound URL
|
||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||
-> audit before file bytes
|
||||
|
||||
authenticated material-bundle issue -> one <=120s deterministic PDF URL
|
||||
-> ww.previewFile({ url, nameWithExtension, exactSize })
|
||||
-> audit before exact cached PDF bytes
|
||||
```
|
||||
|
||||
## Scenario: WeCom Member Data Is A Read-Only Source For OA
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: importing directory members, changing OA employee/profile or manager
|
||||
routes, enabling directory sync, or adding any WeCom member create/update/disable
|
||||
call.
|
||||
- Purpose: keep WeCom as the member-account fact source and prevent an ordinary
|
||||
OA account-management action from unexpectedly changing the real WeCom directory.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Approved inbound CLI: `npm run import:wecom-directory-export -- --input=-
|
||||
--dry-run|--apply`; apply retains its existing dual gate and writes OA only.
|
||||
- OA workflow manager field: `Employee.direct_manager_id`; it is not the WeCom
|
||||
directory supervisor field.
|
||||
- Outbound safety gates: `WECOM_CONTACT_SYNC_ENABLED=false` and effective feature
|
||||
flag `hr_wecom_directory_sync_v1=force_off` for formal internal operation.
|
||||
- Existing outbound API family `/cgi-bin/user/create|update` and disable payloads
|
||||
remain unavailable to ordinary OA user/manager maintenance.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- A directory export or approved read API may create/update the minimum OA
|
||||
employee mirror and `WecomIdentityBinding`; it must not create, update or
|
||||
disable the WeCom member.
|
||||
- WeCom name, department, supervisor, phone, email and active state are edited by
|
||||
the member in an allowed WeCom surface or by a WeCom administrator. OA learns
|
||||
later changes through another approved inbound read/import.
|
||||
- Setting `direct_manager_id` changes only OA workflow routing. It does not imply
|
||||
or trigger a WeCom supervisor update.
|
||||
- An OA account route may record a skipped directory-sync run while the outbound
|
||||
gate is disabled, but it must stop before token acquisition or an external
|
||||
member-write request. The skipped state is evidence of zero external writes.
|
||||
- Any future reverse member sync requires a separate Trellis task, per-field
|
||||
side-effect design, dry-run, explicit production approval and rollback. It may
|
||||
not be introduced as an implicit side effect of an existing OA route.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Import input conflicts or is ambiguous -> block the OA write; never merge or
|
||||
repair the WeCom member automatically.
|
||||
- OA manager/profile action with outbound gate disabled -> OA transaction may
|
||||
succeed, outbound sync is `skipped`, zero WeCom access-token/member-write calls.
|
||||
- Effective outbound flag unexpectedly enabled during ordinary internal operation
|
||||
-> deployment/operation finding; do not exercise the write path to diagnose it.
|
||||
- A requested WeCom profile change -> direct the member or WeCom administrator to
|
||||
WeCom; do not patch the OA mirror and push it outward.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: administrator exports one existing member, OA imports one `employee`
|
||||
account and identity binding, then sets an OA-only manager while outbound sync
|
||||
remains skipped.
|
||||
- Base: a member later changes phone/email in WeCom; OA keeps the previous mirror
|
||||
until the next approved inbound refresh.
|
||||
- Bad: `POST /api/users/batch/set-manager` silently calls `/cgi-bin/user/update`,
|
||||
or OA profile editing becomes a back door for changing the WeCom directory.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Directory-export tests prove stdin-only parsing, dual apply gate, employee-only
|
||||
role and no outbound client invocation.
|
||||
- User manager/profile route tests assert an effective disabled outbound gate
|
||||
produces `skipped` before token acquisition/fetch while preserving OA audit.
|
||||
- Env/feature-contract checks keep the formal default off and reject a production
|
||||
claim that relies on an unapproved reverse-sync flag.
|
||||
- A separately proposed reverse-sync task must add mocked API payload assertions,
|
||||
negative permission/conflict cases and explicit live approval evidence.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
OA set-manager/profile update -> WeCom /user/update -> real member changed
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
WeCom member/admin edits source data -> approved inbound read/import -> OA mirror
|
||||
OA direct_manager_id update -> OA workflow only; outbound member sync skipped
|
||||
```
|
||||
|
||||
## Scenario: Compensatory Leave Is A WeCom Leave Category
|
||||
|
||||
### 1. Scope / Trigger
|
||||
@@ -168,9 +369,10 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
|
||||
- `login|bind` always keeps `snsapi_base`; only explicit `onboard` uses
|
||||
`snsapi_privateinfo`.
|
||||
- The member must be visible to the app, active, return the same userid from
|
||||
both identity calls, and authorize a valid phone plus personal email or
|
||||
business email. A blank/invalid personal email may fall back to a valid
|
||||
business email.
|
||||
both identity calls, and authorize a valid personal email or business email.
|
||||
Phone may be absent; when returned it must be normalized and checked for
|
||||
uniqueness. A blank/invalid personal email may fall back to a valid business
|
||||
email.
|
||||
- `user_ticket`, OAuth code, access token and raw provider responses exist only
|
||||
in request memory. Do not persist them or include them in Cookie, audit,
|
||||
response details or logs; provider/network failures use fixed safe codes.
|
||||
@@ -190,8 +392,9 @@ WeCom leave(category=调休) -> WeCom approval -> OA minimum read-only history
|
||||
- Provider/network failure -> `wecom_api_failed` or safe auth error; no raw
|
||||
payload, token or ticket in output/logs.
|
||||
- Userid mismatch -> `identity_mismatch`; inactive member -> `member_inactive`.
|
||||
- Missing/invalid phone, both emails invalid, or unresolved department ->
|
||||
`missing_required_field`.
|
||||
- Both emails invalid or unresolved department -> `missing_required_field`.
|
||||
- Phone absent -> continue with `NULL`; phone present but conflicting ->
|
||||
`employee_conflict`.
|
||||
- Multiple employees, unique-field race or cross-binding -> `employee_conflict`.
|
||||
- Department binding points to missing/archived data -> `department_invalid`;
|
||||
multiple same-name OA departments -> `department_ambiguous`.
|
||||
@@ -232,3 +435,320 @@ ordinary login -> snsapi_base
|
||||
one-time onboard -> explicit private consent -> safe unique employee-only write
|
||||
business role assignment -> separate administrator-audited operation
|
||||
```
|
||||
|
||||
## Scenario: Passwordless First WeCom Login For Existing OA Accounts
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing ordinary `mode=login`, identity binding, directory-email
|
||||
matching, the password login fallback or account-activation readiness.
|
||||
- Purpose: every existing active OA employee must be able to enter from the
|
||||
WeCom workbench on the first attempt without entering an OA password, while
|
||||
ambiguous identities remain fail-closed.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Entry: `GET /api/auth/wecom/login?returnTo=<internal-path>` with
|
||||
`snsapi_base`.
|
||||
- OAuth callback identity: internal `userid` from `/cgi-bin/auth/getuserinfo`.
|
||||
- Unbound member profile: `GET /cgi-bin/user/get?userid=<oauth-userid>` using
|
||||
the in-memory app access token.
|
||||
- Binding DB: unique `WecomIdentityBinding.employee_id` and
|
||||
`WecomIdentityBinding.external_user_id`.
|
||||
- Audit actions: `AUTH_WECOM_AUTO_BOUND` followed by
|
||||
`AUTH_WECOM_LOGIN_SUCCESS`; the latter records only `bindingSource` and the
|
||||
fixed allowlisted `entrySource`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Existing active binding is the fast path and does not call `/user/get`.
|
||||
- Without a binding, exact OA `employee_no == userid` is a valid stable match.
|
||||
A normalized WeCom `biz_mail`/`email` may also match one existing active OA
|
||||
`Employee.email` using case-insensitive exact equality. If userid and email
|
||||
point to different employees, stop.
|
||||
- Transactionally recheck both unique binding directions before create. A
|
||||
concurrent unique-constraint race is a conflict, not permission to overwrite.
|
||||
- Ordinary login never creates an employee, updates roles, writes the WeCom
|
||||
directory, matches by name/phone, or consumes a pending cookie during OA
|
||||
password login.
|
||||
- Access tokens, full userid, enterprise email and raw provider payload stay in
|
||||
request memory. Auto-bind audit contains only `matchBy` and
|
||||
`emailMatched`; errors and redirects use stable categories.
|
||||
- Missing enterprise email does not block an exact userid/employee-number
|
||||
match. Without either stable match, no session or binding is created.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Existing binding + active employee -> direct session.
|
||||
- Existing binding + inactive employee -> `account_mapping_inactive`.
|
||||
- `/user/get` network/API failure or no OA match ->
|
||||
`account_mapping_required`; no write.
|
||||
- WeCom member inactive or matched OA employee inactive ->
|
||||
`account_mapping_inactive`; no write.
|
||||
- Userid/email point to different employees, either side already has a
|
||||
different binding, identity mismatch or binding race ->
|
||||
`account_mapping_conflict`; no overwrite.
|
||||
- Password login with a legacy `wecom_login_bind` cookie -> normal password
|
||||
login only, clear the cookie, return `wecomBindingStatus=null`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: an unbound active member's enterprise email matches exactly one active
|
||||
OA employee; callback creates one binding and audit, then redirects into the
|
||||
requested OA page without a password.
|
||||
- Base: a previously bound member uses the existing fast path and creates only
|
||||
the normal WeCom login session/audit.
|
||||
- Bad: bind by display name, overwrite another userid, create a new employee,
|
||||
or ask the member to enter an OA password to complete first binding.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Service tests: profile userid equality, member state, business-email
|
||||
normalization, userid-only match, email-only match, split-key conflict,
|
||||
inactive employee, cross-binding and redacted audit.
|
||||
- Route tests: existing binding fast path, passwordless auto-bind + session,
|
||||
provider-unavailable fail-closed redirect and no pending binding cookie.
|
||||
- Password-route regression: a legacy pending cookie cannot create/update a
|
||||
binding and is cleared.
|
||||
- Frontend typecheck/build and focused WeCom notice tests; full backend suite,
|
||||
Prisma validation, sensitive-content and Trellis governance gates.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
unbound OAuth userid -> password login -> consume pending cookie -> bind
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
OAuth userid -> existing binding OR exact userid/unique enterprise email match
|
||||
-> transactional binding -> redacted audit -> direct WeCom session
|
||||
```
|
||||
|
||||
## Scenario: Hosted PC WeCom Web Login
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: adding or changing ordinary-browser WeCom login, the public WeCom
|
||||
config response, OAuth state/callback handling or login-entry audit.
|
||||
- Purpose: let an existing OA employee use the WeCom-hosted Web login page from
|
||||
a normal PC browser without loading third-party scripts beside OA password or
|
||||
email-code inputs.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Public config: `GET /api/auth/wecom/config` returns
|
||||
`pcWebLoginEnabled: boolean` and `pcLoginUrl: string | null` separately from
|
||||
the workbench `loginUrl`.
|
||||
- OA entry: `GET /api/auth/wecom/login?returnTo=<internal-path>&entrySource=pc_qr`.
|
||||
- Provider entry: `https://login.work.weixin.qq.com/wwlogin/sso/login` with
|
||||
`login_type=CorpApp`, CorpID, AgentID, callback and signed state.
|
||||
- Shared callback: `GET /api/auth/wecom/callback?code=<one-time-code>&state=<signed-state>`.
|
||||
- Env gate: `WECOM_PC_WEB_LOGIN_ENABLED=false` by default.
|
||||
- Audit source: `entrySource=pc_qr | wecom_workbench`; `mode` remains
|
||||
`login | bind | onboard`.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- The OA login page uses a normal redirect to the WeCom-hosted page. Do not add
|
||||
`@wecom/jssdk`, a WeCom CDN script or a QR iframe to the credential page.
|
||||
- `entrySource` is independent of `mode`, is validated against a fixed server
|
||||
allowlist and never accepts `pc_qr` for bind/onboard. Missing source in an
|
||||
in-flight legacy state means `wecom_workbench`; an unknown signed value fails.
|
||||
- PC state remains a short-lived HMAC-signed payload and is correlated to the
|
||||
initiating browser with a short-lived HttpOnly, Secure-in-production,
|
||||
SameSite=Lax cookie. Validate and clear it before code exchange.
|
||||
- The cookie is a CSRF/browser-correlation control, not server-side nonce
|
||||
storage. Normal-browser replay fails after cookie clearing; deliberate replay
|
||||
of both URL and old cookie is finally stopped by WeCom's one-time, five-minute
|
||||
code contract and must not create a second OA session.
|
||||
- Reuse existing userid mapping, fail-closed auto-binding, session limit,
|
||||
device fingerprint, returnTo allowlist and structured mapping notices.
|
||||
- Successful login audit contains only `bindingSource` and `entrySource`.
|
||||
Code, state, nonce, cookies, full userid, Secret and raw provider payload stay
|
||||
out of logs, audit and browser error details.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- PC env gate off or base entry config incomplete -> `pcLoginUrl=null`; direct
|
||||
PC entry is unavailable while workbench/password/email-code behavior remains.
|
||||
- `pc_qr + bind/onboard` or unknown query source -> validation failure before
|
||||
redirecting to WeCom.
|
||||
- Tampered/expired state, unknown callback source, missing/mismatched/expired
|
||||
correlation cookie or missing code -> fail before provider code exchange.
|
||||
- Signed PC state after the PC gate is turned off -> clear the cookie and fail.
|
||||
- Existing binding/unique auto-match -> shared session flow; mapping required,
|
||||
conflict or inactive -> existing stable notice and no session.
|
||||
- Replayed URL plus old cookie -> provider rejects the already-consumed code;
|
||||
OA session and success audit counts remain unchanged.
|
||||
- External/absolute/protocol-relative returnTo -> safe internal default.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: normal PC browser redirects to the hosted page, scans, returns with a
|
||||
correlated callback and reaches the requested internal OA page.
|
||||
- Base: WeCom workbench continues through `snsapi_base` with
|
||||
`entrySource=wecom_workbench`; missing source remains compatible during a
|
||||
rolling deployment.
|
||||
- Bad: add `mode=qr`, load an SDK next to password fields, expose Secret in the
|
||||
frontend, accept callback without browser correlation, or claim a signed
|
||||
cookie is a server-consumed nonce.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Route tests assert config gate, exact hosted host/path and parameters, signed
|
||||
source/state, secure cookie attributes and PC bind/onboard rejection.
|
||||
- Callback tests assert missing/mismatched/expired cookie, expired/tampered
|
||||
state, missing code, unknown source and gate-off failures occur before fetch.
|
||||
- Success/replay tests assert shared mapping and session behavior, sanitized
|
||||
`entrySource`, cookie clearing and exactly one created session.
|
||||
- Frontend E2E asserts PC-only button, safe returnTo/device fingerprint and no
|
||||
duplicate button in a WeCom user agent; typecheck and production build pass.
|
||||
- Run full backend tests, Prisma validation, external/readiness contracts,
|
||||
sensitive-content scan, process/Trellis gates and `git diff --check`.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```text
|
||||
normal browser -> mode=qr -> third-party SDK on OA credential page
|
||||
-> callback trusts signed state alone -> audit raw userid
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```text
|
||||
normal browser -> entrySource=pc_qr -> OA sets correlation cookie
|
||||
-> WeCom-hosted login -> shared callback validates state + cookie
|
||||
-> one-time code -> existing userid mapping -> sanitized session audit
|
||||
```
|
||||
|
||||
## Scenario: WeCom Directory Export With Optional Phone
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: importing an administrator-exported WeCom directory XLSX, or
|
||||
changing `Employee.phone` across database, API, HR sync, OAuth and UI layers.
|
||||
- Purpose: allow a real member to remain temporarily without a phone while
|
||||
preserving stable identity, uniqueness and privacy controls.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- DB: `Employee.phone String? @unique`; migration drops only the `NOT NULL`
|
||||
constraint and keeps the unique index.
|
||||
- CLI: `npm run import:wecom-directory-export -- --input=- --dry-run`.
|
||||
- Apply: `WECOM_DIRECTORY_EXPORT_APPLY=1 ... --input=- --apply
|
||||
--actor-employee-no=<employee-no>`.
|
||||
- When apply runs through Compose v1, pass the confirmation variable into the
|
||||
container explicitly: `docker-compose exec -T -e
|
||||
WECOM_DIRECTORY_EXPORT_APPLY server ...`; exporting it only in the host shell
|
||||
does not satisfy the in-container gate.
|
||||
- Binary stdin reader: `fs.readFileSync(0)`; do not assume
|
||||
`fs.promises.readFile(0)` accepts a numeric file descriptor on the production
|
||||
Node runtime.
|
||||
- User API and frontend employee/user types expose `phone: string | null`;
|
||||
create/update accepts an omitted or explicit `null` phone.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Missing phone is stored as SQL `NULL`, never `""`, a repeated placeholder or
|
||||
a synthesized fake number. Multiple `NULL` rows are valid; non-null phones
|
||||
remain unique.
|
||||
- WeCom account and valid enterprise email remain required for directory-export
|
||||
onboarding. Account, non-null phone and enterprise email duplicates block
|
||||
the input.
|
||||
- An empty source phone never clears an existing OA phone. Only an explicitly
|
||||
supplied valid phone participates in update and unique-conflict checks.
|
||||
- The XLSX is accepted only from stdin and defaults to dry-run. Apply requires
|
||||
both `--apply` and `WECOM_DIRECTORY_EXPORT_APPLY=1`, plus an active actor.
|
||||
- Read the XLSX directly from fd 0 without a temporary file. A runtime that
|
||||
rejects the stdin read must stop before Prisma sync; do not fall back to
|
||||
persisting the workbook on the host or in the container.
|
||||
- CLI output contains counts and categories only. The workbook, full local path,
|
||||
names, accounts, phones, emails, departments and raw rows must not enter Git,
|
||||
Trellis artifacts or reports.
|
||||
- New accounts receive only `employee`; finance, CEO and admin roles remain a
|
||||
separate administrator-audited operation.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Missing phone cell -> valid profile with `phone=null`.
|
||||
- Supplied malformed phone -> incomplete/blocked; do not downgrade it to an
|
||||
absent phone and do not write it.
|
||||
- Missing/invalid enterprise email, account, name or department -> skipped or
|
||||
blocked as `missing_required_field`.
|
||||
- Duplicate account/non-null phone/enterprise email in the workbook ->
|
||||
`duplicate_values`; error output contains counts only.
|
||||
- Existing OA unique-field or binding conflict -> conflict; no automatic merge.
|
||||
- `--apply` without confirmation env or active actor -> refuse before sync.
|
||||
- Confirmation env exported only on the Compose host ->
|
||||
`apply_confirmation_required`; no sync/write. Add the name-only `-e` inherit
|
||||
flag and rerun the same approved input.
|
||||
- Promise-based stdin fd read returns `ERR_INVALID_ARG_TYPE` on the deployed
|
||||
Node runtime -> no sync/write; use the fd-compatible reader and rerun dry-run
|
||||
before requesting apply approval.
|
||||
- Migration rollback with any `phone IS NULL` row -> stop; do not fill fake
|
||||
values merely to restore `NOT NULL`.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: five members have unique phones, one member has no phone, all six have
|
||||
unique accounts and enterprise emails; dry-run plans six records and reports
|
||||
only aggregate counts.
|
||||
- Base: a later export still omits a member phone; the member's existing OA
|
||||
phone is preserved.
|
||||
- Base: the local and production Node versions differ; the same binary stdin
|
||||
workbook parses through `fs.readFileSync(0)` without a temporary file.
|
||||
- Base: Compose v1 inherits the already-set non-secret apply confirmation only
|
||||
when `exec -e WECOM_DIRECTORY_EXPORT_APPLY` is present.
|
||||
- Bad: write an empty string or shared placeholder, use personal email in place
|
||||
of the required enterprise email for bulk identity, or auto-grant a business
|
||||
role during import.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Prisma validation plus a migration review proving only `DROP NOT NULL`.
|
||||
- Unit tests for instruction rows/Chinese headers, five-present/one-missing,
|
||||
malformed phone, missing enterprise email and duplicate account/phone/email.
|
||||
- Unit regression asserts the CLI stdin helper calls `fs.readFileSync(0)`;
|
||||
production dry-run must prove aggregate parsing/sync counts before apply.
|
||||
- Apply acceptance must include a negative preflight without the container env
|
||||
gate and a successful approved run with the name-only Compose `-e` flag;
|
||||
both outputs remain count/category only.
|
||||
- Route/service tests for create/update/import `null`, non-empty unique checks,
|
||||
OAuth missing phone and empty-source preservation of an existing phone.
|
||||
- Frontend typecheck/build and displays that render a clear empty-phone label.
|
||||
- Full backend regression, Trellis governance, sensitive-content scan and
|
||||
`git diff --check`; a real workbook may be parsed only into aggregate counts
|
||||
and remains Git-ignored.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
phone: source.phone || "00000000000"
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
phone: source.phone || null
|
||||
// Existing employee updates omit `phone` entirely when the source is empty.
|
||||
```
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
const workbook = await fs.promises.readFile(0);
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const workbook = fs.readFileSync(0);
|
||||
```
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# 隔离备份恢复演练设计
|
||||
|
||||
## 设计结论
|
||||
|
||||
采用“ARM 最新正式日归档 + 本轮私有临时副本 + 无宿主端口的一次性 PostgreSQL 16 + 空附件目录”的数据恢复方案。腾讯云生产只做演练前后公网 `/ready` 读取,不执行任何远端生产写操作;开发机不接收真实备份。
|
||||
|
||||
## 环境选择
|
||||
|
||||
| 方案 | 数据移动与风险 | 对生产影响 | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| ARM 一次性影子恢复 | 备份已在 ARM,只在同机复制到私有临时区;明文恢复范围最小 | 无生产容器操作 | 采用 |
|
||||
| 开发机本地 Docker | 需要把真实 DB/附件下载到个人开发机,扩大敏感数据副本 | 无运行影响,但数据治理风险高 | 不采用 |
|
||||
| 腾讯云同机影子 Compose | 不需跨机下载,但会在真实生产宿主创建容器、目录和资源负载 | 生产宿主存在误操作和资源竞争风险 | 不采用 |
|
||||
|
||||
ARM 在本设计中仍是备份与恢复演练节点,不是生产部署目标。一次性 PostgreSQL 不发布端口;本轮不启动 OA server。
|
||||
|
||||
## 数据流与信任边界
|
||||
|
||||
```text
|
||||
ARM 正式日归档(只读)
|
||||
│ 选择最新 complete 批次并校验
|
||||
▼
|
||||
本轮 0700 临时副本
|
||||
├── database.dump ──> 一次性 PostgreSQL 16 影子库 ──> migration/表计数检查
|
||||
└── storage.tar.gz ─> 本轮空附件目录 ───────────────> 文件数/引用/哈希检查
|
||||
│
|
||||
▼
|
||||
仅数量与状态的脱敏证据
|
||||
```
|
||||
|
||||
正式归档不被解包、改名、删除或直接用作写入目标。临时副本、影子数据库和恢复附件目录都由本轮唯一 run id 标识,清理只允许命中该标识。
|
||||
|
||||
## 恢复点合同
|
||||
|
||||
1. 从正式日归档中按 manifest `createdAt` 选择最新批次,而不是按目录 mtime 猜测。
|
||||
2. 候选目录只能包含三个普通文件;manifest 必须为 schema v1、`complete`,并通过 run id、文件名、字节数和 SHA-256 自校验。
|
||||
3. `buildSha` 必须唯一解析为本仓库已知提交,并且是演练开始时公网 `/ready` 实测当前构建的祖先或相同;同时保留 source image digest 是否存在的布尔证据,不输出完整 digest。相等状态仅用于度量发布后何时形成同源恢复点,不再决定原始备份是否可恢复。
|
||||
4. 演练开始时批次年龄不得超过 24 小时。候选不合格直接 `HOLD`,不自动降级到旧批次。
|
||||
5. 临时副本复制完成后再次验证三文件和 manifest,证明恢复输入与正式归档一致。
|
||||
|
||||
目录身份校验只适用于 ARM 正式归档源目录:其 basename 必须与 manifest `runId` 配对。临时副本允许使用本轮固定私有名称;对临时副本必须继续验证 manifest 合同、run id 格式、三文件集合、字节数、SHA-256、构建和时间,并与源目录首次校验得到的脱敏元数据完全一致,但不得要求临时目录 basename 冒充正式归档名称。
|
||||
|
||||
## 隔离恢复合同
|
||||
|
||||
### 数据库
|
||||
|
||||
- 使用随机一次性容器名和含 `drill` 的数据库名;容器无 `ports`,不加入现有业务网络,不挂载生产或 ARM 归档目录。
|
||||
- 使用 PostgreSQL 16,与现有恢复基线和脚本一致。
|
||||
- 调用 `scripts/restore-db.sh`,同时满足 `RESTORE_SCOPE=shadow`、精确数据库确认和显式 Docker 容器身份。
|
||||
- 恢复后只运行 `SELECT`:migration 成功/失败状态、固定关键表行数和附件引用聚合。不执行 Prisma migrate、seed、DDL 或修复语句。
|
||||
- 从 manifest 来源提交动态取得 migration 目录名称集合,与恢复库成功且未回滚的 `_prisma_migrations.migration_name` 做精确集合核对;不硬编码数量。再静态比较来源提交与当前构建的 migration 集合,记录“相同”或“当前有待升级 migration”。
|
||||
|
||||
固定 12 类表为:部门、员工、员工角色、采购申请、报销单、发票池、付款批次、合同、项目、采购附件、报销附件、付款回单附件。本次已知来源和当前构建现场均为 65 个 migration,但执行器必须使用动态名称集合,不能把 65 写成长期断言。
|
||||
|
||||
### 附件
|
||||
|
||||
- 调用 `scripts/restore-storage.sh` 恢复到不存在或为空的本轮目录。
|
||||
- 恢复前验证 tar 可读、无绝对路径、无 `..`、无软/硬链接;恢复后比较普通文件数。
|
||||
- 对使用本地存储的发票、采购、报销、付款回单和请假附件记录,只在影子库内读取 `storage_path`、大小和哈希用于机器校验,输出仅保留总数和不一致数。
|
||||
- `s3:` 引用不属于当前 tar 恢复范围;若最新备份中出现则 `FAIL` 并登记类别,不尝试访问外部对象存储。
|
||||
|
||||
## RTO/RPO 口径
|
||||
|
||||
- `RPO = drill_started_at - manifest.createdAt`。它表示使用该最新批次恢复时最多回退的数据时间窗口。
|
||||
- `data_restore_RTO = all_data_checks_passed_at - restore_copy_started_at`。起点位于全部只读前置门禁通过后、复制正式批次到临时工作区之前;终点为 DB、附件及关联检查全部通过。
|
||||
- `data_restore_RTO` 不只输出总秒数,还必须按 runner 的实际串行边界输出八个非负整数阶段:`batch_copy`、`postgres_container_start`、`dump_list`、`target_confirmation`、`restore_apply`、`database_verify`(migration 与 12 类表)、`storage_attachment_verify`(附件恢复、文件数与业务关联)和 `final_integrity`(源/副本末次身份复核)。总数应与八段之和一致;若因秒级取整存在误差,差值必须为 `0`,不另设无法归属的隐藏开销。
|
||||
- 阶段计时以 runner 内连续边界为准:每一段结束时立即关闭本段并开始下一段。最长 60 秒的 TCP 目标确认轮询只计入 `target_confirmation`;1 秒轮询粒度是当前演练器实现成本,不与实际 restore apply 混算。失败时已完成阶段输出实测秒数,当前失败阶段输出从其起点到失败的秒数,未运行阶段为 `NOT_RUN`。
|
||||
- 阶段分解用于区分批次复制、容器与演练器等待、数据库/附件实际恢复及一致性校验成本;它们仍共同属于“数据恢复演练 RTO”。本轮未启动 OA 应用、未验证登录或业务 smoke,因此总数和任何阶段都不得称为完整 OA 灾难恢复 RTO,也不形成 SLA。
|
||||
- `same_build_recovery_point_lag` 的正式口径需要可审计的发布完成时刻与 ARM 原子归档完成时刻;当前证据不具备后者,因此不得输出精确正式值。内部诊断可记录 `manifest_creation_after_contract_dropin = manifest.createdAt - automatic-backup service 构建 drop-in 最大 mtime`,但必须明确它既不是应用发布时刻,也不是 ARM 到达时刻。首次严格构建门禁失败仍证明发布后存在一段没有当前构建同源恢复点的窗口;该画像独立于数据 RPO 和数据恢复 RTO,即使恢复没有开始也保留。
|
||||
- 前置访问准备、等待人工输入和任务文档整理不计入 RTO;镜像拉取、批次复制、实际恢复和验证命令计入。
|
||||
- 本轮不启动应用,因此不得称为“完整 OA 恢复 RTO”;生产 `/ready` 只证明演练未影响在线服务。
|
||||
|
||||
## 状态判定
|
||||
|
||||
- `GO`:AC1–AC7 全部满足,可证明最新成对数据备份按其来源 schema 可恢复;若来源与当前 migration 集合不同,必须同时标注当前 schema 升级未验证,不能宣称完整灾难恢复 GO。
|
||||
- `PAUSE`:访问权限、磁盘空间、Docker/PostgreSQL 16 运行条件或清理验证不足,但未发现备份内容损坏。
|
||||
- `NO_GO`:最新批次超龄/损坏、来源提交未知或不是当前构建祖先/相同、DB restore 失败、来源 migration 集合或关键表异常、附件缺失/哈希不一致、DB 与附件不配对,或生产受到影响。
|
||||
|
||||
重试不得覆盖首次失败证据。若修正环境性问题后重试,必须使用新 run id 并保留首次脱敏 finding。
|
||||
|
||||
## Attempt 1 后的只读身份探针
|
||||
|
||||
首次恢复入口因最新 manifest `build_mismatch` 失败关闭后,不直接重装恢复入口。该失败证据保留,但严格相等门禁已被证明会在发布后自然备份到达前产生必然假阻断。后续先安装一个固定无参数、运行后自删除的 ARM root-owned 只读探针,权限仅用于读取 receiver 的路径配置和最新正式日归档:
|
||||
|
||||
- 按 manifest `createdAt` 选择最新正式日批次,不自动改选旧批次。
|
||||
- 只输出 manifest 构建短标识、与当前生产短标识的相等状态、批次年龄、run id/目录配对状态、source image digest 是否存在,以及两份产物的文件类型、大小和 SHA-256 聚合校验状态;祖先关系在本地仓库只读验证,不要求 ARM 持有 Git 历史。
|
||||
- 不输出完整 run id、完整 digest、完整路径、字节数、文件名之外的业务信息或原始 manifest。
|
||||
- 不复制、不解包、不启动 Docker、不读取数据库内容或附件内容,不修改正式归档。
|
||||
- 成功或失败均删除探针自身和对应 sudoers;普通用户暂存文件在 SHA-256 精确匹配后另行清理。
|
||||
|
||||
探针结果不能替代恢复验收。最新 manifest 与当前生产不相等时,先判断其是否为当前构建祖先:是祖先且批次完整、新鲜时可进入原始恢复重试审批,同时记录同源恢复点滞后;未知或非祖先才维持 `NO_GO` 并转入备份链调查。手工触发生产备份始终是可撤回备选项,不是完成项。
|
||||
|
||||
## Attempt 3 后的数据库恢复诊断合同
|
||||
|
||||
Attempt 3 只留下 `database_restore_failed` 聚合类别,失败清理同时删除了权限 `0600` 的私有原始日志。删除潜在敏感日志是正确安全边界,但下一轮 runner 必须在清理前生成只含 allowlist 状态的阶段诊断,不能再次盲重试。
|
||||
|
||||
- 数据库恢复拆成 `dump_list`、`target_confirmation`、`restore_apply` 三个独立阶段;每个阶段只输出 `PASS`、`FAIL` 或 `NOT_RUN`,并保留稳定错误类别,不输出 pg_restore 原始 stderr、对象名、路径、凭据或 SQL。诊断必须在失败路径进入清理前写到标准输出,清理仍由 trap/finally 等价路径保证执行;输出先于清理不表示保留私有原始日志。
|
||||
- `dump_list` 使用与实际恢复相同的 PostgreSQL 16 容器和临时副本,只解析 custom dump 目录,不执行数据库写入。失败时分类为 `dump_list_failed` 并停止。
|
||||
- `target_confirmation` 只确认当前数据库名等于含 `drill` 标记的本轮目标,并确认容器连接可用;失败时分类为 `restore_target_confirmation_failed`。
|
||||
- 只有前两项通过才允许调用现有 `restore-db.sh`。调用必须记录执行路径枚举 `DOCKER_STDIN` 或 `HOST_FILE`、脚本退出码和真实 pg_restore apply 退出码;ARM runner 固定使用 `DOCKER_STDIN`,两条路径都由本地集成回归直接驱动仓库中的 `scripts/restore-db.sh`。此后非零分类为 `restore_apply_failed_after_list_pass`,证明 dump 可解析但实际应用失败;它仍不能自动等同于备份内容损坏。
|
||||
- 对 pg_restore 非零不设置“可忽略对象错误”白名单:即使私有日志末尾可脱敏提取到对象错误计数,任何非零仍为 `FAIL/NO_GO`。计数只输出非负整数或 `UNKNOWN`,不得据此把部分恢复升级为 `PASS`;未来若要允许部分成功,必须另行列出错误类别、证明对象集合完整并重新批准设计。
|
||||
- 私有原始日志继续保持 `0600` 且不得复制出 ARM;成功或失败都随本轮 runtime 删除。清理前将三项阶段状态写入最终标准输出,保证失败原因至少可定位到合同边界。
|
||||
- 合成回归必须分别制造 dump 列表失败、目标确认失败和列表通过但 apply 失败,并断言后续阶段保持 `NOT_RUN`、输出不含 fixture Secret/路径/SQL、容器与 runtime 清理为零;至少一条注入失败用例必须同时证明诊断在清理前已输出、清理完成后该诊断仍可从调用方捕获。成功回归直接以 PostgreSQL 16 custom dump 驱动真实 `scripts/restore-db.sh` 的 Docker stdin 与宿主文件两条路径,不重写等价恢复逻辑。
|
||||
- 该修复只改变演练器诊断能力,不放宽正式归档、源/副本身份、24 小时新鲜度、shadow 数据库、无端口、附件或清理门禁。任何 Attempt 4 实际恢复仍须使用新 run id、重新探测最新批次并另行批准。
|
||||
|
||||
## Attempt 4 前的 runner 持久化门禁提案
|
||||
|
||||
个人 `.trellis/workspace/` 中的 runner 和回归不会提交,不能作为 Attempt 4 的可重复证据。该限制不接受;实际恢复前应在另行批准的本地代码阶段完成以下持久化,不与 ARM 探针或恢复操作混在一起:
|
||||
|
||||
- 将当前一次性恢复 runner 收敛为受版本控制、只含占位符和校验逻辑的 `scripts/qa/offhost-shadow-restore-runner.template.sh`,将附件关联校验器作为 `scripts/qa/validate-offhost-shadow-restore-attachments.py`。Attempt 4 候选必须由合同脚本使用私有 JSON 参数机械渲染,并与现有 `scripts/restore-db.sh`、`scripts/restore-storage.sh` 一起打包;不得从聊天或个人 workspace 重新生成等价实现。
|
||||
- 新增 `scripts/qa/run-offhost-shadow-restore-runner-contract.sh`,直接校验候选 runner 的 Bash 语法、依赖 SHA-256、固定无参数入口、源/副本身份重绑、动态 migration 集合、三阶段脱敏诊断、退出码、失败先诊断后清理及敏感输出边界。
|
||||
- 合同脚本直接以 PostgreSQL 16 custom dump 驱动真实 `scripts/restore-db.sh` 的 Docker stdin 和 host-file 两条路径,并覆盖 dump-list 失败、目标确认失败、apply 非零、后续阶段 `NOT_RUN` 和零残留。测试失败不得上传候选。
|
||||
- 远端地址、现场路径、远端账号、一次性容器/数据库命名、timer、构建前缀和 migration 集合指纹均不进入模板;由私有 JSON 提供并按字段类型、字符集和安全命名规则验证后替换。渲染器拒绝额外/缺失字段、换行、shell 语法、非绝对或逃逸路径和错误摘要格式,不从不受信环境变量覆盖。
|
||||
- 私有 JSON 和渲染后的现场候选只能位于仓库外,或位于仓库内专用的 `/.restore-drill-private/` ignored 目录。渲染器必须对输入和输出分别执行 Git ignore 判定;任一路径在仓库内但未被忽略时,在创建候选前失败关闭,不能依赖人工避免 `git add -A`。
|
||||
- 权限 `0600` 的私有原始日志只允许初始化时截断一次;容器启动、dump list、目标确认、restore apply 和附件恢复各写固定阶段标记并使用追加模式。这样后续阶段失败时仍能在本轮 runtime 内交叉核对之前阶段,最终清理和脱敏输出边界不变。
|
||||
- 上传前由合同脚本对实际候选文件生成/校验 SHA-256;ARM 安装前再次校验同一摘要。证据记录合同脚本提交、候选摘要和 PASS,不记录地址、路径或业务数据。
|
||||
|
||||
## Attempt 4 后的 PostgreSQL 稳定就绪修订提案
|
||||
|
||||
Attempt 4 在 `dump_list=PASS/0` 后、实际 restore apply 前以 `restore_target_confirmation_failed` 失败关闭。只读环境差异调查确认:runner 以第一次 `pg_isready` 成功作为容器就绪,而合同夹具因曾观察到 PostgreSQL 首次初始化临时服务关闭窗口,已改为连续两次真实查询成功;两者没有复用同一就绪实现。该问题按 `local_contract_vs_arm_environment_difference_investigation` 登记,高置信度根因为 `postgres_initialization_readiness_race`,不能归因于备份不可恢复。
|
||||
|
||||
修订只允许改变跟踪 runner 与其可复跑合同,不修改 `scripts/restore-db.sh`,不放宽目标身份、归档、shadow、无端口、恢复非零失败关闭或清理门禁:
|
||||
|
||||
1. 删除 runner 的“首次 `pg_isready` 成功即通过”判定。`pg_isready` 最多可作为诊断,不能成为允许 restore 的充分条件。
|
||||
2. `dump_list` 保持不连接目标数据库的独立阶段;其成功只证明 custom dump 目录可解析,不计入数据库稳定就绪次数。
|
||||
3. `target_confirmation` 在容器内显式通过 TCP 回环 `127.0.0.1` 执行真实 `SELECT current_database()`,不用默认 Unix socket。官方 PostgreSQL 镜像的 initdb 临时服务器不监听 TCP,而正式服务器监听容器内回环;因此 TCP 成功是排除临时初始化服务器的结构性判据。`--network none` 不影响容器自己的 loopback,实际 restore 仍可继续沿现有 socket 路径,不修改 `scripts/restore-db.sh`。
|
||||
4. TCP 目标确认使用最多 60 次、每次间隔 1 秒的有界窗口作为额外保险。每次查询退出码非零时把连续成功计数重置为 `0`;查询成功且数据库名精确等于本轮 shadow 目标时累计,只有连续两次成功才 `PASS`;查询成功但名称不匹配立即失败,不继续重试。连续成功不是排除临时服务器的唯一依据,不能移除 TCP 回环判据后单独保留时序启发式。
|
||||
5. 脱敏输出增加 `target_confirmation_exit_code`、`target_name_match=yes|no|NOT_RUN`、`target_confirmation_attempt_count` 和 `target_confirmation_success_streak`。退出码取最后一次实际查询的非负整数;未执行为 `NOT_RUN`。错误类别区分 `TARGET_QUERY_NONZERO` 与 `TARGET_NAME_MISMATCH`,不得输出查询文本、数据库名、路径、凭据或原始 stderr。
|
||||
6. 稳定就绪函数必须定义在被渲染 runner 中;合同从 rendered runner 提取或直接驱动该同一实现,不得在 fixture 外围另写一套更宽松或更稳定的等待逻辑。fixture 自身如需等待,也必须通过同一实现进入后续真实脚本双路径回归。
|
||||
7. 实施前审计 runner 合同的每个覆盖机制,区分“直接执行跟踪脚本/校验器”“从 rendered runner 提取同一实现”“只做静态结构断言”和“合同自行重写等价逻辑”。最后一类必须消除或登记为明确限制,不能以组件测试通过推断未执行的 runner 编排行为。
|
||||
8. 回归至少覆盖:Unix socket 可用但 TCP 仍失败时不得通过;首次 TCP 查询失败后连续两次成功可继续;一次成功后失败会重置 streak;数据库名错配立即失败;查询持续非零在第 60 次后失败;每条失败路径的 apply 保持 `NOT_RUN`、诊断先于清理且残留为 `0`;原有 dump、restore 双路径、身份、migration、附件和敏感输出合同继续全绿。
|
||||
9. 禁止用固定长 sleep、增加 ARM 特例、降低连续成功次数、跳过 TCP 回环或名称精确比较、只修改合同夹具来修复。若相同实现的本地合同通过而 ARM 仍在该阶段失败,保留环境差异 finding,依据新退出码和尝试计数继续调查,不现场放宽。
|
||||
|
||||
本节已于 2026-08-25 获准仅在本地实施和回归;授权不包含连接 ARM 或执行 Attempt 5。修复通过后仍须提交可追溯变更、重新选择当时最新批次、重跑只读身份探针、重新渲染并核对候选 SHA-256,再单独申请 Attempt 5 实际恢复。
|
||||
|
||||
## 清理与回滚
|
||||
|
||||
- 生产无变更,无生产回滚步骤。
|
||||
- 成功或失败都只删除本轮 run id 对应的一次性容器、临时附件目录和临时批次副本;归档目录、receiver 管理目录和现有监控容器不在清理匹配范围。
|
||||
- 清理前再次解析容器名和临时根目录;任何对象不满足本轮标识、类型或父目录约束时停止自动清理。
|
||||
- 清理后确认无本轮容器、宿主端口、临时网络和临时目录残留;失败则结论至少为 `PAUSE`,交由管理员精确处置。
|
||||
|
||||
## 证据边界
|
||||
|
||||
执行证据写入当前 task 的 `research/restore-drill-evidence.md`,随后只在现有 OPS7 Runbook 或人工试运行报告中补一条脱敏状态摘要;不新建 `docs/` 平行文档。不得保存主机地址、SSH 别名、完整远端路径、Secret、连接串、真实账号、表内容、附件名或原始日志。
|
||||
@@ -0,0 +1,213 @@
|
||||
# 隔离备份恢复演练实施计划
|
||||
|
||||
## 执行前门禁
|
||||
|
||||
- [x] 确认任务已在用户审阅最终规划摘要后由 `task.py start` 进入 `in_progress`。
|
||||
- [x] 只读确认本地 `main`、当前生产构建事实、来源/当前 migration 集合和现有恢复脚本;现场两构建均为 65 且目录无变化,但后续执行不硬编码数量。
|
||||
- [x] 只读确认生产公网 `/ready` 正常;不读取生产数据库或执行生产写入。
|
||||
- [ ] 连接 ARM 后只读核对 receiver 配置有效、最新正式日批次身份、dead-man 状态、Docker 可用性和磁盘余量;不列出完整目录或原始 manifest。
|
||||
- [ ] 磁盘安全预算至少满足“批次总字节数 5 倍 + 2 GiB”;不足则 `PAUSE`。
|
||||
- [ ] 若当前账号没有对正式归档只读和本轮临时区/Docker 的最小权限,停止并给用户一条窄权限操作步骤;不得申请通用 root shell 或扩大既有健康监控 sudo 入口。
|
||||
|
||||
## 有序执行清单
|
||||
|
||||
1. 生成不含主机信息的本轮 run id,记录 UTC 开始时间和生产前置 `/ready` 状态。
|
||||
2. 在 ARM 正式日归档中选择 manifest 时间最新的 complete 批次,验证三文件、字节数、SHA-256 与 24 小时 RPO 门禁;在本地仓库确认来源提交是当前构建祖先或相同,并单独记录是否严格相等。
|
||||
3. 创建权限 `0700` 的本轮临时工作区;启动 RTO 计时,复制整个批次并对临时副本重新执行同一校验。
|
||||
4. 创建无宿主端口、无业务网络、无持久 volume 的一次性 PostgreSQL 16 容器;数据库名含 `drill`,凭据随机且仅存在本轮进程环境。
|
||||
5. 使用 `scripts/restore-db.sh` 的 shadow 合同恢复数据库;任何警告、确认不匹配或 restore 非零立即停止后续验证并进入清理。
|
||||
6. 从来源提交动态冻结 migration 名称集合;通过错误即停的只读 SQL 验证恢复库成功且未回滚的 migration 集合精确匹配来源、无失败/未知项,并静态记录来源与当前构建的 migration 差异;随后验证固定 12 类关键表并记录行数。
|
||||
7. 使用 `scripts/restore-storage.sh` 恢复附件到本轮空目录;比较归档与解包普通文件数。
|
||||
8. 在影子库中聚合本地附件引用,以不输出路径的校验程序验证路径安全、文件存在及适用的大小/SHA-256;记录引用总数和不一致数。
|
||||
9. 所有数据检查通过时停止 RTO 计时;按 manifest 时间计算 RPO,记录当前发布到首个同源恢复点的可用滞后,形成区分“原始数据可恢复性”和“当前 schema 升级状态”的 `GO/PAUSE/NO_GO` 脱敏结论。
|
||||
10. 清理本轮容器、临时网络(若有)、临时附件目录、临时批次副本和临时凭据;确认无本轮残留。
|
||||
11. 再次只读检查生产公网 `/ready` 和 ARM dead-man/健康 timer 状态,确认演练没有影响在线生产与现有监控。
|
||||
12. 将证据写入当前 task,并在既有事实源补充当前状态;不提交任何真实备份、日志或业务内容。
|
||||
|
||||
## Attempt 1 执行状态
|
||||
|
||||
- 最新 ARM 候选在 `select_latest_batch` 阶段因 manifest `build_mismatch` 失败关闭;复制、容器创建、数据库恢复和附件恢复均未开始。
|
||||
- 失败后的只读复核确认:公网 `/ready` 与生产自动备份 unit 的构建短标识一致,两个生产侧事实均为 `7b86ffb3`;因此当前不是计划事实源过期或备份 unit 冻结构建漂移。
|
||||
- ARM 一次性 root 入口和本轮 runtime 均为零残留,两个监控 timer 仍为 active;本轮 5 个普通用户暂存文件已按 SHA-256 精确匹配并删除,未宽泛删除目录。
|
||||
- Attempt 1 保持 `NO_GO`。在当时取得最新 ARM manifest 的脱敏构建短标识、run id 配对关系和 manifest 摘要一致性前,不进入第 3 步及之后的恢复流程。
|
||||
|
||||
## Attempt 1 后只读调查清单
|
||||
|
||||
1. 生成固定无参数的 root-owned ARM 只读身份探针和最小 sudoers,只允许读取 receiver 路径配置及最新正式日归档。
|
||||
2. 本地执行 `bash -n`、候选 SHA-256、sudoers candidate `visudo`(环境可用时)和禁止写入/恢复关键字人工复核。
|
||||
3. 上传到 ARM 普通用户私有暂存目录;由用户输入 sudo 密码安装固定入口,安装前再次核对全部候选 SHA-256 和系统 sudoers 语法。
|
||||
4. 运行一次固定入口,记录构建短标识、批次年龄、manifest 配对、产物聚合完整性和入口自清理状态。
|
||||
5. 对照公网 `/ready` 与生产自动备份 unit 的当前短标识:相等时记录同源恢复点已形成;不相等时在本地仓库核对是否为当前构建祖先,祖先且完整/新鲜仍可形成原始恢复重试方案,未知或非祖先才 `NO_GO`。
|
||||
6. 按 SHA-256 精确删除普通用户暂存文件;不得删除 ARM 正式归档或宽泛清理目录。
|
||||
|
||||
### 只读调查执行结果
|
||||
|
||||
- [x] 探针候选通过本地/ARM SHA-256、Bash 语法和 ARM sudoers candidate 校验。
|
||||
- [x] 固定入口只读读取最新正式批次并自删除;未复制、解包、启动容器或恢复数据。
|
||||
- [x] 最新批次 `ec682565` 在 24 小时内,run id/目录配对及 DB/附件大小、SHA-256 均通过,但与当前 `7b86ffb3` 不匹配。
|
||||
- [x] 只读对时确认自动备份合同刷新晚于最新批次生成;finding 收敛为“发布后尚未产生新构建恢复点”。
|
||||
- [x] root 入口残留为 0,3 个普通用户候选及暂存目录已按精确身份清理,两个 ARM 监控 timer 仍 active。
|
||||
- [x] 08-24 最新自然批次只读探针通过:来源与当前构建相同、年龄不超过 24 小时、run id 配对及 DB/附件 SHA-256 均通过;临时入口和暂存零残留。当前可提交恢复重试审批,尚未获得恢复授权。
|
||||
|
||||
### Attempt 2 恢复重试结果
|
||||
|
||||
- [x] 用户批准后安装并执行动态 migration 集合版固定恢复入口。
|
||||
- [x] ARM 正式归档最新批次首次门禁通过,并开始复制到本轮私有临时区。
|
||||
- [x] 临时副本复检因 runner 错把正式源目录 basename 身份规则应用到固定名 `batch` 而触发 `batch_identity`;失败阶段为 `create_isolated_runtime`,数据库容器和恢复均未开始。
|
||||
- [x] runtime、root 入口、6 个普通用户候选和暂存目录均精确清理为零残留,两个 ARM 监控 timer 保持 active。
|
||||
- [x] 修订 runner:正式源目录继续强制 basename/run id 配对;临时副本 manifest run id 重绑到源侧已验证 run id,并继续校验完整内容合同及源/副本元数据精确一致。补齐 2 正例 + 3 负例后重新执行只读身份探针,实际恢复另行批准。
|
||||
|
||||
### Attempt 3 准备批准边界
|
||||
|
||||
- [x] 用户已批准修复 runner,并重新执行 08-25 最新正式批次只读身份探针。
|
||||
- [x] 回归已覆盖:正式源通过、同内容改名副本通过、内容篡改副本失败、源 basename/run id 错配失败、副本 run id 与源侧已验证 run id 不同失败。
|
||||
- [x] Attempt 3 未复用 Attempt 2 新鲜度:08-25 最新批次年龄约 6 小时 11 分,来源与当前构建相同,run id 配对、大小与 SHA-256 均通过。
|
||||
- [x] 用户复核探针后明确批准 Attempt 3 实际恢复,范围仅限 ARM 隔离影子恢复。
|
||||
|
||||
### Attempt 3 恢复结果与停止点
|
||||
|
||||
- [x] 修复后的候选在本地和 ARM 通过 SHA-256、Bash 与 sudoers 语法校验后安装;正式归档门禁和临时副本源侧身份重绑通过。
|
||||
- [x] runner 在 `restore_database` 阶段因 `restore-db.sh` 返回非零而以 `database_restore_failed` 失败关闭;migration、12 类关键表、附件恢复与关联验证均未执行。
|
||||
- [x] 一次性容器、本轮 runtime、临时 root 入口和普通用户候选均精确清理为零残留;ARM 两个监控 timer 保持 active,生产演练后 `/ready` 仍为相同构建的 `PASS`。
|
||||
- [x] 在任何 Attempt 4 前修复失败诊断合同:分别记录 dump 列表校验与实际 restore 的脱敏阶段码,并确保失败清理前只保留安全聚合诊断;本地回归已通过,远端探针和实际恢复仍需重新批准。
|
||||
|
||||
### Attempt 4 前的修复清单
|
||||
|
||||
1. runner 在调用 `restore-db.sh` 前,以同一 PostgreSQL 16 容器对临时副本执行独立 `pg_restore --list`,记录 `dump_list=PASS|FAIL|NOT_RUN`。
|
||||
2. 独立确认影子目标数据库名与连接状态,记录 `target_confirmation=PASS|FAIL|NOT_RUN`;不输出数据库名或连接信息。
|
||||
3. 仅在前两项通过后执行实际 restore,记录 `restore_apply=PASS|FAIL|NOT_RUN`;失败类别区分为 `dump_list_failed`、`restore_target_confirmation_failed` 或 `restore_apply_failed_after_list_pass`。
|
||||
4. 原始 stderr 只进入本轮 `0600` 私有日志并随 runtime 删除;最终输出只含 allowlist 阶段状态和既有脱敏聚合。
|
||||
5. 补三条阶段负例、敏感输出负例和每条失败路径零残留回归;通过后重新执行最新批次身份探针,再请求 Attempt 4 实际恢复批准。
|
||||
|
||||
### Attempt 3 后本地修复结果
|
||||
|
||||
- [x] `dump_list`、`target_confirmation`、`restore_apply` 固定输出 `PASS/FAIL/NOT_RUN`,并增加执行路径、脚本/pg_restore 退出码、对象错误计数与稳定类别。
|
||||
- [x] 诊断在清理前输出;注入 apply 退出码 `23` 后,调用方在 runtime/access 清理完成后仍可读取阶段状态和退出码。
|
||||
- [x] partial-success 规则保持失败关闭:任何 pg_restore 非零均为 `FAIL/NO_GO`,不设置可忽略对象错误白名单。
|
||||
- [x] PostgreSQL 16 custom dump 直接驱动真实 `scripts/restore-db.sh` 的 Docker stdin 与 host-file 两条路径,均恢复成功。
|
||||
- [x] dump-list 失败、目标确认失败、apply 失败及敏感输出负例通过;既有成对备份/恢复回归和自动异机备份合同通过。
|
||||
- [x] 用户另行批准并完成 Attempt 4 前最新批次只读探针:构建 `SAME`、年龄约 7 小时 24 分、run id/产物完整性与 migration 集合门禁通过,入口及暂存零残留;尚未执行实际恢复。
|
||||
|
||||
### Attempt 4 前的持久化门禁
|
||||
|
||||
1. 将已验证的 runner 和附件校验器从个人 workspace 收敛为 `scripts/qa/` 下的跟踪文件,不改变恢复边界或生产脚本。
|
||||
2. 将八类 runner 回归收敛为可独立执行的 `run-offhost-shadow-restore-runner-contract.sh`,直接驱动真实 `restore-db.sh` 双路径。
|
||||
3. 合同脚本必须机械验证三阶段诊断、执行路径、两个退出码、非零失败关闭、源/副本身份、dynamic migration、敏感输出和清理顺序;对候选生成稳定 SHA-256。
|
||||
4. 运行新合同、既有成对备份/恢复、自动异机备份 74 项、Trellis 治理与敏感扫描;全部通过后才允许把跟踪文件打包为 Attempt 4 候选。
|
||||
5. 本阶段仅本地修改与测试;新批次探针结果和持久化门禁均通过后,再单独申请 Attempt 4 实际恢复。
|
||||
|
||||
### Attempt 4 前持久化门禁结果
|
||||
|
||||
- [x] runner 已收敛为 `scripts/qa/offhost-shadow-restore-runner.template.sh`;现场路径、账号、容器/数据库命名、timer、构建和 migration 指纹均为经严格校验的私有渲染字段。
|
||||
- [x] 附件关联校验器已收敛为 `scripts/qa/validate-offhost-shadow-restore-attachments.py`。
|
||||
- [x] `scripts/qa/run-offhost-shadow-restore-runner-contract.sh` 可独立复跑并提供 `render` 模式;相同私有配置生成确定性候选 SHA-256,shell 注入在输出候选前失败关闭。
|
||||
- [x] 源/副本身份、内容篡改、dynamic migration、附件安全、三阶段诊断、真实 restore 双路径、NOT_RUN、退出码、清理顺序和敏感输出合同全部通过。
|
||||
- [x] 新增三个文件由 `check-sensitive-content.mjs` 定向扫描,`3/3` 文件、finding `0`;本地测试容器残留 `0`。
|
||||
- [x] `.gitignore` 增加 `/.restore-drill-private/` 专用目录;合同分别证明仓库内未忽略的私有 JSON、未忽略的现场候选均在创建输出前失败,ignored 输入/输出正例通过。
|
||||
- [x] 私有原始日志保留唯一初始化截断,五个固定阶段均以标记分隔并追加写;静态合同拒绝后续阶段再次截断。
|
||||
- [x] 完整复跑 runner 合同、既有隔离备份/恢复演练和自动异机备份 74 项;task、Trellis、process、默认/定向敏感扫描及空白门禁通过,测试容器和本地私有路径残留均为 `0`。
|
||||
- [x] Attempt 4 最新批次只读探针重新执行并通过;候选已由提交 `5ec7db1` 的跟踪模板确定性渲染,完整合同、依赖钉住和 SHA-256 复核通过。
|
||||
- [x] 用户批准 Attempt 4 后,候选在 ARM 安装前再次通过 SHA 清单与语法校验并执行;`dump_list=PASS/0`,随后在 `target_confirmation` 阶段失败,实际 restore apply、migration、关键表和附件均未运行。
|
||||
- [x] Attempt 4 runtime、临时 root 入口和普通用户上传暂存均清理为 `PASS/0`;按预先裁决规则转入本地合同路径与 ARM 实际环境的只读差异调查,不直接修补 runner 或创建 Attempt 5。
|
||||
- [x] 只读差异矩阵与本地精确复现收敛到 PostgreSQL 初始化就绪竞态:合同夹具等待连续两次真实查询,runner 仅等待首次 `pg_isready`;本地稳定数据库上的同一 target query 为 `PASS/0`,ARM 所有临时对象复核残留 `0`。
|
||||
- [x] Attempt 4 后正式归档只读探针与执行前为同一构建和同一 manifest 脱敏摘要,run id 配对、产物 SHA-256、24 小时新鲜度均通过;探针入口与暂存残留 `0`,正式归档内容级零修改已补证。
|
||||
- [x] 演练后腾讯云生产 `/ready` 已由执行方沿既有只读路径补证:状态 `PASS`、12 位构建前缀 `7b86ffb3c9ef`,与演练前一致;未记录地址或原始响应。
|
||||
- [x] 用户已批准仅在本地实施和回归 PostgreSQL 稳定就绪修复;该批准不包含连接 ARM 或执行 Attempt 5。
|
||||
|
||||
### Attempt 5 前仅本地修复结果
|
||||
|
||||
- [x] 在跟踪 runner 中以容器内 TCP 回环 `127.0.0.1` 的真实 `SELECT current_database()` 作为结构性就绪判据,并以最多 60 次、间隔 1 秒、连续两次成功且名称精确匹配作为额外保险,替换首次 Unix socket `pg_isready` 即通过逻辑;名称错配立即失败,查询非零重置连续成功计数。
|
||||
- [x] 增加 target query 退出码、名称匹配、尝试次数和连续成功次数的 allowlist 诊断,区分查询非零与名称错配;私有原始日志、失败先诊断后清理和零残留边界不变。
|
||||
- [x] 可复跑合同直接提取并驱动 rendered runner 中同一稳定就绪函数;fixture 外围与 runner 不同源的等待循环已删除。
|
||||
- [x] “socket 可用但 TCP 不可用仍失败”、首次失败后恢复、streak 重置、名称错配立即失败、永久查询失败有界退出五类回归通过;真实 `restore-db.sh` Docker stdin/host-file、源/副本身份、dynamic migration、附件安全、敏感输出和清理合同继续通过。
|
||||
- [x] 已审计合同全部覆盖机制,未发现第二套自写等价恢复逻辑;完整 runner 的 migration/12 类表/附件编排仍只能由真实隔离恢复验证,已作为明确限制保留,不能由本地组件合同替代。
|
||||
- [x] `scripts/restore-db.sh` 相对生产构建字节未改;runner、成对备份/恢复、自动异机备份 74 项、task、Trellis、process、docs、敏感扫描和空白门禁均通过。
|
||||
- [x] 本地修复阶段未连接 ARM、未生成或安装 Attempt 5 现场入口;提交后已重新选择最新批次、执行只读探针、重新渲染候选并核对 SHA-256,实际恢复另行取得了明确批准。
|
||||
|
||||
### Attempt 5 前只读身份探针结果
|
||||
|
||||
- [x] 用户批准后仅安装并执行最新正式批次只读探针;未复制、解包、启动容器、读取业务数据或执行恢复。
|
||||
- [x] 最新批次来源与当前生产构建相同,探针时年龄 `64625` 秒且不超过 24 小时;run id 配对、DB/附件大小与 SHA-256、source digest 均通过。
|
||||
- [x] 来源/当前 migration 目录为 `65/65`、差异 `0`;仓库 HEAD 相对生产构建的应用代码漂移文件数 `0`。
|
||||
- [x] 探针 root 入口与普通用户暂存残留均为 `0`;按已验证私有配置取得的 dead-man/健康 timer 实际 unit 状态为 `active/active`。
|
||||
- [x] 本次探针完成时 RTO 八阶段实现尚未批准,因此当时未渲染 Attempt 5 最终候选、未生成最终候选 SHA-256,也未执行实际恢复;后续本地实现授权单独追加,不改写该时点事实。
|
||||
|
||||
### Attempt 5 前 RTO 分阶段实施(已批准仅本地实现)
|
||||
|
||||
- [x] 设计口径已固定为总 RTO 加八段串行秒数:批次复制、容器启动、dump list、目标确认、restore apply、migration/12 类表、附件恢复与关联校验、最终源/副本复核。
|
||||
- [x] runner 增加固定 allowlist 阶段字段,成功时八段均为非负整数且精确加总为 `data_restore_rto_seconds`;失败时已完成段和当前失败段保留数字,后续段为 `NOT_RUN`。
|
||||
- [x] 合同注入各阶段成功/失败,验证最长 TCP 轮询只进入目标确认阶段、后续 `NOT_RUN`、总数精确加总、输出不含路径/SQL/凭据;原有清理与恢复合同继续通过。
|
||||
- [x] 用户批准的当前边界仅为本地修改 runner、更新可复跑合同并执行回归;不连接 ARM、不重新渲染 Attempt 5 现场候选、不执行实际恢复。
|
||||
- [x] 八阶段计时与完整合同通过后,满足 24 小时门禁的批次已直接进入 Attempt 5 候选渲染、SHA-256 核对、只读探针和实际恢复审批流程;期间未插入新的非阻断增强。
|
||||
- [x] 八阶段状态必须在失败路径清理前输出;阶段边界沿用 `dump_list`、`target_confirmation`、`restore_apply` 等既有诊断词汇,不建立第二套同义命名。
|
||||
|
||||
### Attempt 5 最终候选与执行前探针
|
||||
|
||||
- [x] 从提交 `0b94b97` 的跟踪模板和 ignored 私有配置完成两次确定性渲染;候选字节一致、完整 runner 合同通过,候选 SHA-256 为 `7904052aa5a8c31d54734bc2fbf2d9caf2dac8b0116135119519f8467b3dae27`。
|
||||
- [x] 候选固定的是生产构建前缀和来源 migration 集合指纹,不固定探针时的 source run id;实际执行会在 ARM 上重新选择当时最新正式批次,并对该批次重新执行完整身份、产物 SHA-256、24 小时 RPO 和 migration 集合门禁。
|
||||
- [x] 最终只读探针重新选择执行时最新正式批次:来源构建与当前生产相同,年龄 `68889` 秒且不超过 24 小时,run id/目录配对、DB/附件完整性和 source digest 均通过。
|
||||
- [x] 本地复核来源/当前 migration 为 `65/65`、差异 `0`,候选 migration 指纹与来源集合一致,应用代码漂移和 `scripts/restore-db.sh` 字节漂移均为 `0`。
|
||||
- [x] 探针 root 入口、sudoers 和普通用户暂存残留均为 `0`;本地 runner 合同容器残留为 `0`。本阶段未复制、解包或恢复正式归档,未连接腾讯云生产。
|
||||
- [x] Attempt 5 实际隔离恢复已在新鲜度门禁成立时另行批准并执行;紧邻执行前探针通过,但 runner 在选择实际恢复批次之前即因依赖库入口合同失败,因此本轮不存在“实际选择批次”,不能宣称恢复输入与探针批次相同。
|
||||
|
||||
### Attempt 5 实际结果与安装器差异调查
|
||||
|
||||
- [x] 安装前候选 SHA-256 与已批准值精确一致;紧邻执行前只读探针通过,批次年龄 `70444` 秒,来源构建与当前生产相同,run id 配对、产物完整性和 source digest 门禁通过。
|
||||
- [x] runner 在 `entry_contract` 阶段以 `restore_library_missing` 失败关闭;批次选择、正式归档复制、PostgreSQL 容器启动、数据库/附件恢复和全部八阶段 RTO 均未运行,原始结果保留为 `NO_GO`。
|
||||
- [x] 失败分类为 `installer_path_mismatch`:已批准 runner 从固定 `libexec` 位置加载三项依赖,本轮一次性安装器却把同一组依赖安装到相似但不同的 `lib` 位置。该结论指向演练安装器,不证明 runner 或备份不可用。
|
||||
- [x] 对错误安装目录做精确只读检查:三项依赖均为 root 拥有的普通文件、SHA-256 前缀与本地包一致、额外条目数 `0`;随后只清理该已确认目录并得到 `runner_lib_cleanup=PASS`。
|
||||
- [x] 诊断脚本在 sudo 上下文中用 `$HOME` 推断普通用户暂存位置,导致自删除误指向 root 家目录;随后由普通用户外层对已知暂存脚本执行精确清理。后续安装器不得在 sudo 脚本中用 `$HOME` 推断调用用户路径。
|
||||
- [x] Attempt 5 后腾讯云生产 `/ready` 为 `PASS`,12 位构建前缀仍为 `7b86ffb3c9ef`;失败发生在归档选择之前,未触碰腾讯云生产或正式归档内容。
|
||||
- [x] 最终只读复核确认 runner/probe 入口与 sudoers、正确 `libexec`、错误 `lib`、普通用户暂存、Docker 容器和 runtime 六类残留全部为 `0`,dead-man/健康 timer 为 `active/active`;复核脚本自身清理 `PASS`。
|
||||
- [x] ignored/workspace 安装器已把三项依赖安装目标对齐 runner 已批准的 `libexec` 合同;诊断脚本改用经 basename、普通文件和非链接校验的调用路径自清理,不再以 sudo `$HOME` 推断普通用户路径。runner 与候选字节未改、无需重新渲染。
|
||||
- [ ] 再次实际恢复前重新选择满足 24 小时门禁的新鲜批次,执行只读身份探针并单独申请实际恢复批准;不得复用 Attempt 5 的批次新鲜度或既有实际恢复授权。
|
||||
|
||||
### 08-27 最新批次只读身份探针
|
||||
|
||||
- [x] 安装器与 rendered runner/probe 的共享路径完成本地静态核对:runner `SELF_PATH/LIB_DIR/SUDOERS_PATH`、probe `SELF_PATH/SUDOERS_PATH` 均在首次特权写入前精确比对;`WORK_ROOT` 未在安装器重复编码,sudoers candidate 从最终路径推导。
|
||||
- [x] 探针上传准备中一次 basename 截取错误被远端 SHA-256 校验在普通用户暂存阶段拦截;未执行 sudo、未安装入口、未读取归档。三个已确认截短文件逐个精确清理后,以真实 basename 重传并通过三文件 SHA-256 与 Bash 语法校验。
|
||||
- [x] 最新正式批次只读探针执行成功:来源构建与当前生产相同,run id/目录配对、DB/附件产物完整性和 source digest 均通过;探针入口与普通用户暂存残留为 `0/0`。
|
||||
- [x] 最新批次年龄 `124137` 秒,约 34 小时 29 分,超过 24 小时门禁 `37737` 秒,约 10 小时 29 分;本次恢复前置判定为 `NO_GO/stale_latest_batch`,不得改选旧批次或执行实际恢复。
|
||||
- [x] 用户批准后完成生产与 ARM 两侧只读调查:daily/watchdog timer 均按计划触发但最近 service 均 `exit-code/1`;ARM 最新原子归档超龄且存在两个不完整 partial,定位为生产到 ARM 的传输未完成,不是调度漏跑或完整传输后的 finalize 失败。
|
||||
- [x] dead-man 实际阈值为 `93600` 秒并正确记录 `FAIL_OFFHOST_DEADMAN/archive_stale`;journal 聚合存在一次 `sent=true`,证明 detector 与发送调用未被去抖静默抑制。健康 timer 保持 active,两端调查入口和暂存均清理为零残留。
|
||||
- [x] 两个 partial 证明 SSH 会话已完成初始认证并开始写入,后续排查不再以密钥失效、authorized_keys 或 host key 初始拒绝为首要假设;独立事件调查优先采集 partial 聚合字节偏移、真实 rsync 退出码和传输持续时间。
|
||||
- [ ] 在生产自动备份传输恢复、ARM 形成新的 24 小时内原子批次之前,恢复演练保持阻塞;补跑备份、修复传输配置/网络、修改 timer 或发送测试告警均需新的明确批准。
|
||||
- [ ] 将生产到 ARM 的传输中断拆为独立 Trellis 生产事件任务,当前演练任务只保留交叉引用和阻塞关系;创建任务、生产侧只读深查与实际修复均另行取得批准。
|
||||
|
||||
## 验证命令类别
|
||||
|
||||
- 本地/仓库:`bash -n` 覆盖备份、DB 恢复、附件恢复和 receiver 脚本;只读统计 migration 目录。
|
||||
- ARM 前置:receiver `validate-config`、systemd 公共状态、Docker 版本/容量、候选 manifest 脱敏校验。
|
||||
- 恢复输入:`sha256sum`、manifest schema/文件集/字节数校验、`pg_restore --list`、`tar -tzf`/`tar -tvzf`。
|
||||
- DB 验证:`psql -v ON_ERROR_STOP=1` 的 migration 名称/状态集合和固定表计数查询;本地用 Git 比较来源/当前 migration 目录,不执行 migrate。
|
||||
- 附件验证:`find` 普通文件计数及不输出路径的 Python 只读校验;只输出引用数和 mismatch 数。
|
||||
- 任务门禁:
|
||||
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-23-isolated-backup-restore-drill`
|
||||
- `node scripts/qa/check-trellis-governance.mjs`
|
||||
- `node scripts/qa/check-process-contract.mjs --baseline --json`
|
||||
- `node scripts/qa/audit-docs.mjs --check-registry`
|
||||
- `node scripts/qa/check-sensitive-content.mjs`
|
||||
- `git diff --check`
|
||||
|
||||
具体远端命令在执行阶段依据 ARM 已安装的 root-owned receiver 配置生成,不把真实地址、账号、目录或凭据写入本计划。
|
||||
|
||||
## 停止条件
|
||||
|
||||
- 最新批次不存在、超过 24 小时、manifest 或 SHA-256 不一致,或来源提交未知/不是当前构建祖先或相同。仅“不严格相等”不再单独停止。
|
||||
- 权限、Docker、PostgreSQL 16 或磁盘预算不满足。
|
||||
- 命令目标可能指向腾讯云生产、ARM 正式归档、receiver 管理目录或既有容器/网络。
|
||||
- DB restore、migration、固定表、附件安全、文件引用、哈希/大小任一检查失败。
|
||||
- 生产 `/ready` 在演练期间异常,或现有 ARM dead-man/健康监控状态改变。
|
||||
- 清理对象身份不明确。此时保留现场并报告,不使用宽泛删除命令。
|
||||
|
||||
## 变更与证据文件边界
|
||||
|
||||
- 允许写入:当前 Trellis task、既有 OPS7 Runbook/人工试运行报告中的脱敏状态摘要、本轮 ARM 私有临时区和一次性 Docker 对象。
|
||||
- 禁止写入:产品代码、migration、生产/ARM 正式配置、生产 DB/附件、ARM 正式归档、企业微信。
|
||||
- 计划新增的 task 内证据:`research/restore-drill-evidence.md`;只在实施实际发生后创建。
|
||||
|
||||
## 审查与收口
|
||||
|
||||
- 本任务涉及真实备份数据和远端运维边界。执行完成后做一次只读复核,重点确认:生产零变更、归档未修改、RTO/RPO 口径正确、附件验证没有漏项、敏感内容未进入证据、临时对象已清理。
|
||||
- 若现有脚本需要修改,回到规划阶段补充设计和新的批准;不得在远端临时放宽 shadow/路径/确认门禁。
|
||||
- 门禁通过后用中文 commit 引用 `08-23-isolated-backup-restore-drill`,是否推送仍按用户当时授权执行。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 隔离备份恢复演练
|
||||
|
||||
## Goal
|
||||
|
||||
在不覆盖、不暂停腾讯云生产 OA 的前提下,使用 ARM 备份机上最新的完整异机批次完成一次数据库与附件的隔离影子恢复,证明“备份存在”进一步达到“最新成对数据备份可恢复”,并记录实测数据恢复 RTO、RPO 和脱敏证据。
|
||||
|
||||
## Scope
|
||||
|
||||
- 在 ARM 上选择并校验最新正式日归档,使用本轮临时副本恢复数据库与附件。
|
||||
- 验证 migration、固定关键表、附件数量与 DB/附件引用一致性,记录数据恢复 RTO/RPO。
|
||||
- 复核生产前后 `/ready`、ARM 监控状态和临时对象清理结果,形成当前 task 内脱敏证据并更新既有事实源摘要。
|
||||
- 不修改产品运行代码;允许把演练 runner 模板、附件校验器和可复跑合同收敛到 `scripts/qa/`。生产、正式归档、企业微信和现有监控均保持只读或零变更。
|
||||
|
||||
## Background
|
||||
|
||||
- 腾讯云服务器是真实 OA 生产;ARM 只承担异机备份、外部健康监控和受控恢复演练,不是 OA 生产服务器。
|
||||
- 2026-08-23 只读复核确认:生产最近 14 天每日异机备份和新鲜度 watchdog 均成功,ARM dead-man 最近成功;企业微信中的“恢复通知”来自公网健康状态恢复,不代表执行过数据恢复。
|
||||
- 2026-07-27 曾在 ARM 使用当时的正确异机批次完成真实格式影子恢复,但不能替代对当前最新自动备份的可恢复性验证。
|
||||
- 首次演练时生产产品构建为 `7b86ffb`,已通过公网 `/ready` 和 migration pending=none;当时仓库与来源构建 `ec682565` 均有 65 个 Prisma migration 目录,二者之间 migration 目录无变化。该数字是本次现场事实,不再作为跨构建硬编码合同。
|
||||
- 现有恢复脚本默认只接受带 `shadow`、`drill` 或 `restore_test` 标记的数据库名,并拒绝生产式目标、确认值不一致、非空附件目标和危险归档路径。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 恢复点选择与前置门禁
|
||||
|
||||
- 只选择 ARM 正式日归档中按 UTC 时间最新的一个完整批次,不触碰 inbox、临时事务目录、旧错误批次或周检查点。
|
||||
- 批次必须恰含 `database.dump`、`storage.tar.gz`、`manifest.json` 三个普通文件;manifest 必须为 schema v1、`complete`,相对文件名、字节数、SHA-256 和来源构建身份全部一致。
|
||||
- 批次年龄必须不超过 24 小时。manifest `buildSha` 必须唯一解析为本仓库已知提交,且该提交是演练开始时公网 `/ready` 实测当前构建的祖先或与其相同;未知提交、旁支/后继提交、超龄、损坏或文件集歧义判定 `NO_GO`,磁盘空间不足判定 `PAUSE`。两者都不得自动改选更旧批次掩盖问题。
|
||||
- 构建相等只表示“当前发布后已经形成同源恢复点”,不再作为数据备份可恢复性的必要条件。若来源是当前构建的祖先,必须单独记录发布后同源恢复点滞后 finding,不能把它误报为归档损坏。
|
||||
- 执行前后各做一次公网 `/ready` 只读检查,仅用于证明生产未受影响;该结果不得充当影子恢复后的应用启动证据。
|
||||
|
||||
### R2. ARM 隔离和数据保护
|
||||
|
||||
- 演练仅在 ARM 上创建本轮专属、权限 `0700` 的临时工作区、一次性 PostgreSQL 16 容器和空附件目录;不得在开发机下载真实备份。
|
||||
- PostgreSQL 不发布宿主端口,不加入现有 OA/监控网络,不复用任何现有容器名、数据库名、volume 或目录;影子数据库名必须含 `drill` 标记。
|
||||
- 先把所选批次复制到临时工作区并重新校验 manifest 与三文件 SHA-256,再对临时副本执行恢复;正式 ARM 归档始终只读,不直接解包或改名。
|
||||
- 不读取或复用生产 `.env`、JWT、Webhook、企业微信 Secret、真实数据库口令或业务账号;影子数据库只使用本轮随机临时凭据,凭据不进入聊天、仓库、报告或命令参数证据。
|
||||
- 含现场路径、命名或构建值的私有渲染 JSON 和渲染后候选不得进入 Git 历史;若位于仓库内,必须置于专用 ignored 目录,合同脚本须在创建候选前机械验证该 ignore 状态,未忽略即失败关闭。
|
||||
|
||||
### R3. 数据库恢复验证
|
||||
|
||||
- 使用现有 `scripts/restore-db.sh` 的 `RESTORE_SCOPE=shadow` 和精确数据库名确认门禁,将最新 `database.dump` 恢复到一次性 PostgreSQL 16 容器。
|
||||
- `pg_restore --list` 和实际 restore 必须成功。从 manifest 来源提交动态冻结其 Prisma migration 目录集合;恢复后 `_prisma_migrations` 中成功且未回滚的 migration 名称必须与该来源集合一致,并且不存在失败、回滚中或来源未知的 migration。不得用硬编码数量替代集合核对。
|
||||
- 同时比较来源提交与当前构建的 migration 目录集合:集合相同则记录“当前 schema 无额外升级步骤”;当前构建新增 migration 时,原始备份恢复仍可继续验证,但“升级到当前 schema”标为 `PAUSE/未验证`,另行设计仅针对影子库的 migration 演练,不在本轮临时执行。
|
||||
- 固定核对组织、人员、角色、采购、报销、发票池、付款批次、合同、项目及三类核心附件表的存在性和行数;只记录表名、数量和 PASS/FAIL,不记录业务字段或正文。
|
||||
- 所有验证 SQL 必须只读并开启错误即停;不执行 migration、seed、DDL、数据修补或业务写入。
|
||||
|
||||
### R4. 附件恢复与关联验证
|
||||
|
||||
- 使用现有 `scripts/restore-storage.sh` 的 shadow 门禁恢复 `storage.tar.gz` 到本轮空目录;归档路径、链接类型和目标空目录检查必须通过。
|
||||
- 归档普通文件数必须与解包后普通文件数一致;对数据库中本地存储型的发票、采购附件、报销附件、付款回单和请假附件引用,验证相对路径安全、目标文件存在,并在已有 SHA-256/大小字段时核对哈希或大小。
|
||||
- 发现 `s3:` 引用、缺失文件、路径逃逸、哈希/大小不一致或 DB 与附件批次不配对时判定 `NO_GO`;不输出文件名、原始路径、票据内容或附件内容。
|
||||
|
||||
### R5. 计时、证据和清理
|
||||
|
||||
- `RPO` 定义为演练开始 UTC 时间减 manifest `createdAt`;必须小于等于 24 小时才通过。本次实测值只用于当前恢复演练,不形成 SLA 承诺。
|
||||
- “数据恢复 RTO”从通过前置门禁后、开始复制批次到临时工作区之前计时,到 DB/附件恢复和全部数据一致性检查通过时结束;不得把历史合成演练耗时或生产 `/ready` 计入本轮 RTO。
|
||||
- 另行记录“同源恢复点可用滞后”:当前发布合同刷新时间到首个相同构建的 ARM 完整批次到达时间。该指标与数据 RPO、数据恢复 RTO 分开,不形成 SLA;即使恢复未执行,观测到的发布后空窗仍是有效演练发现。
|
||||
- 证据写入当前 Trellis task,只记录日期、角色、批次脱敏标识、构建短标识、年龄、耗时、数量、状态、finding 类别和清理结果;不保存远端地址、完整目录、凭据、真实账号、业务正文或原始 payload。
|
||||
- 成功或失败后只清理本轮明确创建的一次性容器、临时工作区和临时凭据;清理后确认无容器、端口、临时目录残留。任何目标身份不明确时停止清理并人工复核,绝不删除正式归档。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:ARM 最新正式日批次通过文件集、manifest、字节数和 SHA-256 校验,来源构建是当前构建的祖先或相同,且实测 RPO 不超过 24 小时;是否已形成当前构建同源恢复点单独记录。
|
||||
- [ ] AC2:腾讯云生产在演练前后 `/ready` 均正常,生产容器、数据库、附件、配置、timer 和业务数据零写入、零重启、零切换。
|
||||
- [ ] AC3:一次性 PostgreSQL 16 影子库无宿主端口,数据库恢复成功;恢复 migration 集合与 manifest 来源提交一致且无失败/未知项,来源与当前 migration 差异已明确;固定 12 类关键表均可读并形成仅含数量的脱敏摘要。
|
||||
- [ ] AC4:附件安全恢复成功,归档与解包普通文件数一致;所有纳入范围的本地附件引用均安全且存在,适用的哈希/大小核对无不一致。
|
||||
- [ ] AC5:记录本轮数据恢复 RTO、RPO、同源恢复点可用滞后、开始/结束时间、失败点或 finding、Go/Pause/No-Go 结论;即使恢复未开始也保留已观测空窗,不把任何指标表述为完整应用灾备 RTO 或 SLA。
|
||||
- [ ] AC6:演练结束后本轮容器、端口、网络和临时数据全部清理,ARM 正式归档与现有 dead-man/健康监控不受影响。
|
||||
- [ ] AC7:任务校验、Trellis 治理、流程合同、文档审计、敏感扫描和 `git diff --check` 通过;证据不含敏感信息。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 不在腾讯云生产执行 restore、停机、重启、数据库 reset、migration、DDL、seed、配置修改、timer 修改或业务写入。
|
||||
- 不触发新的生产备份,不删除、替换或轮转 ARM 正式归档,不发送企业微信测试消息。
|
||||
- 不把真实备份下载到开发机,不把 ARM 变成 OA 部署目标或长期运行 OA server。
|
||||
- 本轮不启动隔离 OA 应用容器、不做登录、报销详情或附件下载页面验证,也不对恢复库执行 Prisma migration;因此只形成“原始数据恢复 RTO”和静态 migration 兼容判断,完整应用恢复、schema 升级和切换 RTO 仍需未来灾难恢复专项验证。
|
||||
- 不修改备份/恢复产品代码;若现有脚本在真实最新批次上暴露缺陷,先停止并另行回到规划,不现场放宽安全门禁。
|
||||
|
||||
## Blocking Open Questions
|
||||
|
||||
- 无。构建门禁已裁决为“来源提交是当前构建祖先或相同 + 批次年龄不超过 24 小时”;实施仍需用户审阅本次修订后的最终规划摘要并再次明确批准。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 校验最新批次 manifest、三文件集合、字节数、SHA-256、构建身份和 24 小时新鲜度。
|
||||
- 通过现有 shadow 恢复脚本完成 PostgreSQL 16 和附件恢复,验证来源提交的 migration 集合、与当前构建的 migration 差异、固定 12 类表及附件引用聚合。
|
||||
- 复核无宿主端口、生产前后 `/ready`、ARM 监控未受影响和本轮临时对象零残留。
|
||||
- 运行 task validate、Trellis governance、process contract、docs registry、敏感扫描和 `git diff --check`。
|
||||
@@ -0,0 +1,594 @@
|
||||
# 隔离备份恢复演练证据
|
||||
|
||||
## 证据边界
|
||||
|
||||
- 仅记录日期、角色、短构建标识、状态、finding 类别和清理结果。
|
||||
- 不记录远端地址、SSH 身份、完整目录、凭据、账号、原始 manifest、业务字段或附件内容。
|
||||
- 腾讯云是 OA 生产;ARM 仅是异机备份、外部健康探测和隔离恢复演练节点。
|
||||
|
||||
## Attempt 1:前置门禁失败关闭
|
||||
|
||||
日期:2026-08-23
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 生产公网 `/ready`(演练前) | `PASS` |
|
||||
| 计划基线构建短标识 | `7b86ffb` |
|
||||
| 最新 ARM 正式日批次选择 | 已选择最新候选,未降级到旧批次 |
|
||||
| manifest 来源构建门禁 | `FAIL`:`build_mismatch` |
|
||||
| 失败阶段 | `select_latest_batch` |
|
||||
| finding 类别 | `latest_batch_invalid` |
|
||||
| 数据库/附件复制 | 未开始 |
|
||||
| 一次性 PostgreSQL / 恢复写入 | 未创建、未执行 |
|
||||
| runtime 清理 | `PASS` |
|
||||
| 临时 root 入口清理 | `PASS` |
|
||||
| 结论 | `NO_GO` |
|
||||
|
||||
该结果只证明最新候选未通过来源构建门禁,不能单独证明备份内容损坏。首次失败证据必须保留,禁止自动改选更旧批次或放宽门禁。
|
||||
|
||||
## 失败后只读复核
|
||||
|
||||
日期:2026-08-23
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 生产公网 `/ready` | `PASS`,构建短标识 `7b86ffb3` |
|
||||
| 生产自动备份 timer | `active` |
|
||||
| 生产自动备份 unit 冻结构建 | `7b86ffb3` |
|
||||
| ARM dead-man timer | `active` |
|
||||
| ARM 公网健康 timer | `active` |
|
||||
| ARM 一次性 root 入口残留 | `0` |
|
||||
| ARM 本轮 runtime 残留 | `0` |
|
||||
| ARM 普通用户暂存候选 | 窄名称初扫发现 `1`;随后按本轮 5 个文件的 SHA-256 精确匹配并全部删除 |
|
||||
|
||||
只读复核排除了“当前生产构建事实源过期”和“生产自动备份 unit 仍冻结旧构建”两种解释。ARM 上本轮 5 个普通用户暂存文件均经 SHA-256 与本地候选逐一匹配后精确删除,未执行宽泛目录删除。剩余调查重点是:最新 ARM 正式日批次是否来自当前生产自动备份链,以及 manifest 构建身份在生产生成、异机传输或 ARM 日归档选择中的哪一环发生漂移。
|
||||
|
||||
## Attempt 1 当时判定与重试门禁
|
||||
|
||||
- Attempt 1 仍为 `NO_GO`,当时 AC1、AC3、AC4、AC5 未满足;该次没有形成数据恢复 RTO/RPO 实测值。
|
||||
- 不重试恢复,不触碰 ARM 正式归档,不修改腾讯云生产数据或配置。
|
||||
- 下一步只允许以窄权限读取以下脱敏事实:最新 ARM manifest 的构建短标识、批次时间与最新生产成功状态是否指向同一 run id、manifest 摘要是否一致。
|
||||
- 若确认只是 ARM 最新批次来源/归档选择异常,另行规划修复备份链;若确认归档身份正确且仅演练器口径有误,先更新任务设计并重新批准。
|
||||
- 任何第二次恢复尝试前,必须更新规划、重新生成一次性入口并再次取得用户明确批准。
|
||||
|
||||
## 只读身份探针结果
|
||||
|
||||
日期:2026-08-23
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 最新 ARM manifest 构建短标识 | `ec682565` |
|
||||
| 当前生产/自动备份 unit 短标识 | `7b86ffb3` |
|
||||
| 构建匹配 | `NO` |
|
||||
| 最新批次年龄 | 约 18 小时,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
|
||||
| 探针临时 root 入口清理 | `PASS`,残留 `0` |
|
||||
| 普通用户暂存清理 | 3 个候选均按 SHA-256 精确删除,目录残留 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
|
||||
`ec682565` 是仓库可识别的历史提交,也是当前生产提交的祖先。随后只读对时确认:当前生产自动备份合同约 1.3 小时前才刷新,而最新 ARM 批次约 18 小时前生成;下一次自然调度约 14 小时后执行。因此最新 ARM 批次是在当前发布之前由当时活动构建生成,不支持“错误来源批次”或“传输损坏”的判断。
|
||||
|
||||
当前 finding 应归类为“发布后尚未产生新构建恢复点”。首次恢复入口按当时已批准的精确构建门禁正确失败关闭;该 `NO_GO` 是 Attempt 1 的真实历史结论,不覆盖也不回写。后续设计复盘确认,严格相等不是原始备份可恢复性的必要条件,新的准入合同见 2026-08-24 规划修订。
|
||||
|
||||
当时用户决策:不手工触发生产自动备份,等待既有 timer 自然生成并异机归档下一批恢复点。该决策不是完成项,也不永久排除手工备份;若后续自然批次异常,可重新作为需明确批准的备选项。
|
||||
|
||||
## 2026-08-24 设计复盘与有效产出
|
||||
|
||||
- 自 2026-08-23 对时到当前日期,下一次自然调度窗口已经过去;当前唯一可立即执行的动作是按新设计重新只读探测 ARM 最新批次,而不是继续等待。
|
||||
- Attempt 1 已经观测到:生产合同刷新后,到下一个自然备份抵达前,没有与当前构建严格相等的异机恢复点。该“同源恢复点可用滞后”是独立于数据 RPO/RTO 的有效恢复画像,即使数据库恢复未开始也必须保留。
|
||||
- `ec682565` 与 `7b86ffb3` 的 migration 目录均为 65 个,目录差异为 0;这证明本次历史批次与当前 schema 没有 migration 增量,但不能证明“65”适合作为长期硬编码断言。
|
||||
- 修订后的恢复点门禁为:manifest 来源提交是当前生产构建的已知祖先或相同,批次年龄不超过 24 小时,且 manifest/两份产物完整。严格相等只用于度量同源恢复点何时形成。
|
||||
- migration 验证改为来源提交目录名称集合与恢复库成功集合精确一致;来源/当前集合不同则把“升级到当前 schema”单独标为未验证,不在本轮临时执行 migration。
|
||||
|
||||
## 2026-08-24 最新自然批次只读探针
|
||||
|
||||
本节仅追加,不改写 Attempt 1 及 2026-08-23 既有证据。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 演练开始时公网 `/ready` | `PASS` |
|
||||
| 公网当前构建唯一解析 | `PASS`,短标识 `7b86ffb3` |
|
||||
| 生产自动备份 unit 与公网构建 | `MATCH`,timer `active` |
|
||||
| ARM 最新批次构建短标识 | `7b86ffb3` |
|
||||
| 来源与当前构建关系 | `SAME`;因此同时满足“祖先或相同” |
|
||||
| 批次年龄 | `68491` 秒,约 19 小时 2 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
|
||||
| 来源/当前 migration 目录 | 均为 65 个,差异文件 `0` |
|
||||
| 探针临时 root 入口 | 自清理 `PASS`,复核残留 `0` |
|
||||
| 普通用户暂存 | 3 个候选按 SHA-256 精确删除,目录残留 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
|
||||
同源恢复点画像:生产备份合同刷新距探针约 `94465` 秒;最新同源 manifest 距探针 `68491` 秒,因此该同源批次的 manifest 创建时间约在合同刷新后 `25974` 秒(约 7 小时 13 分)。当前证据没有直接采集 ARM 原子归档完成时刻,不能把该差值表述为精确“到达 ARM”耗时;只能证明同源批次创建滞后约 7 小时 13 分,并且在本次探针时已经可用。
|
||||
|
||||
执行方结论:最新自然批次满足修订后的来源关系、新鲜度、run id 配对和产物完整性门禁,可进入恢复重试审批。该结论不是恢复授权;本次只读探针没有复制或解包备份、创建 Docker 对象、恢复数据库/附件或修改生产与 ARM 正式归档。
|
||||
|
||||
## 2026-08-24 同源滞后锚点更正
|
||||
|
||||
本节追加说明前节 `25974` 秒的锚点,不改写既有记录。
|
||||
|
||||
- 起点实际是生产 `automatic-backup` service 构建 drop-in 的最大文件 mtime;终点是最新 manifest `createdAt`。
|
||||
- 该差值可称为内部诊断指标“合同 drop-in 文件刷新到同源 manifest 创建”,不能称为应用发布完成到恢复点到达 ARM 的精确耗时。
|
||||
- 当前证据没有采集 ARM 原子归档完成时刻,因此正式 `same_build_recovery_point_lag` 只能保留为“存在发布后空窗”的定性 finding,`7h13m` 不对外引用为精确恢复画像。
|
||||
- 2026-08-23 “下一次自然调度约 14 小时后”的估算存在时区处理错误:探针把 systemd 输出的本地时区时间直接附为 UTC,导致约多算 8 小时。修正后自然调度约在当时探针后 6 小时,与 2026-08-24 最新 manifest 创建时间一致;没有证据表明中间发生非计划手工备份。
|
||||
|
||||
## Attempt 2:临时副本目录身份误用
|
||||
|
||||
日期:2026-08-24
|
||||
|
||||
本节仅追加,不改写 Attempt 1、只读探针或锚点更正记录。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 最新正式批次前置门禁 | `PASS` |
|
||||
| 正式归档读取/修改 | 只读使用、零修改 |
|
||||
| 临时副本创建 | 已复制到本轮私有临时区 |
|
||||
| 失败阶段 | `create_isolated_runtime` |
|
||||
| finding 类别 | `copied_batch_invalid` / `batch_identity` |
|
||||
| PostgreSQL 容器 | 未创建 |
|
||||
| DB/附件恢复 | 未开始 |
|
||||
| migration/关键表/附件关联验证 | 未执行 |
|
||||
| runtime 清理 | `PASS`,复核残留 `0` |
|
||||
| 临时 root 入口清理 | `PASS`,复核残留 `0` |
|
||||
| 普通用户暂存清理 | 6 个候选按 SHA-256 精确删除,目录残留 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
| 结论 | `NO_GO` |
|
||||
|
||||
根因:runner 的 `validate_batch` 同时承担正式源目录身份和批次内容校验。正式源目录 basename 必须与 manifest `runId` 配对是正确安全门禁;复制后的私有临时目录固定名为 `batch`,内容仍保持同一 manifest/run id,却被同一个 basename 断言错误拒绝。该 finding 不表示备份损坏,也没有进入数据库或附件恢复。
|
||||
|
||||
修订边界:正式源目录继续执行 basename/run id 强校验;临时副本只跳过目录 basename 断言,manifest、run id 格式、文件集、构建、时间、大小、SHA-256 和与源元数据相等比较全部保留。任何重试前必须补“正式源通过、同内容改名副本通过、任一内容篡改失败”的合成回归并再次取得明确批准。
|
||||
|
||||
## 2026-08-25 Attempt 2 finding 分类补充
|
||||
|
||||
本节只追加分类,不改写 Attempt 2 原始结果。
|
||||
|
||||
- Attempt 2 finding 类别明确为 `runner_defect`,具体子类为“源目录身份断言误用于临时副本”。
|
||||
- Attempt 1 finding 是“发布后尚未形成同源恢复点”;Attempt 2 finding 是演练器缺陷。两者均未证明备份不可恢复,不能合并表述为“备份连续失败两次”。
|
||||
- Attempt 2 的正式批次前置门禁 `PASS`、临时复制完成、恢复前失败关闭、正式归档零修改和所有临时对象零残留,证明安全门禁在错误发生后按设计收口。
|
||||
- 临时副本修订不是简单放宽:副本 manifest `runId` 必须重绑到源侧已验证的 run id,并继续通过 manifest、文件集、构建、时间、字节数、SHA-256 及源/副本元数据相等校验。
|
||||
- 新增强制负例:源目录 basename 与 manifest run id 不配对必须 `NO_GO`;副本 manifest run id 即使内部自洽,但与源侧已验证 run id 不同也必须 `NO_GO`。
|
||||
|
||||
## 2026-08-25 runner 修复验证与最新批次探针
|
||||
|
||||
本节仅追加,不改写 Attempt 1、Attempt 2 或既有探针记录。
|
||||
|
||||
### Runner 修复验证
|
||||
|
||||
| 回归 | 结果 |
|
||||
| --- | --- |
|
||||
| 正式源 basename 与 manifest run id 配对 | `PASS` |
|
||||
| 同内容改名副本重绑源侧 run id | `PASS` |
|
||||
| 副本内容篡改 | 预期失败,`PASS` |
|
||||
| 源 basename 与 manifest run id 错配 | 预期失败,`PASS` |
|
||||
| 副本 manifest run id 内部自洽但不同于源 run id | 预期失败,`PASS` |
|
||||
| 动态 migration 集合正/反例 | `PASS` |
|
||||
| 既有隔离备份/恢复合同回归 | `PASS` |
|
||||
|
||||
实现结果:正式源目录继续校验 basename/run id;临时副本 manifest run id 必须等于源侧已验证 run id,并继续验证 manifest、文件集、构建、时间、大小、SHA-256 及源/副本脱敏元数据完全一致。
|
||||
|
||||
### 08-25 最新自然批次
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 演练开始时公网 `/ready` | `PASS` |
|
||||
| 公网构建与自动备份 unit | `MATCH`,短标识 `7b86ffb3`,timer `active` |
|
||||
| ARM 最新批次构建短标识 | `7b86ffb3` |
|
||||
| 来源与当前构建关系 | `SAME` |
|
||||
| 批次年龄 | `22249` 秒,约 6 小时 11 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件 manifest 大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 摘要 | 已取得 12 位脱敏前缀,不保存完整摘要 |
|
||||
| 来源/当前 migration 目录 | 均为 65 个,差异文件 `0` |
|
||||
| 探针 root 入口及普通用户暂存 | 残留均为 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
|
||||
该批次不是 Attempt 2 的旧候选,新鲜度和全部身份/完整性门禁已重新计算。当前满足 Attempt 3 恢复审批前提,但本次批准不包含实际恢复,未复制、解包或恢复该批次。
|
||||
|
||||
## Attempt 3:数据库恢复阶段失败关闭
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
本节仅追加,不改写 Attempt 1、Attempt 2 或既有探针记录。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 候选文件 SHA-256、Bash 与 sudoers 语法 | `PASS` |
|
||||
| 最新正式批次前置门禁 | `PASS`;使用 08-25 新候选 |
|
||||
| 正式归档读取/修改 | 只读取用;runner 没有归档写入路径 |
|
||||
| 临时副本创建及源侧身份重绑 | `PASS`;否则不会进入数据库恢复阶段 |
|
||||
| 失败阶段 | `restore_database` |
|
||||
| runner 聚合类别 | `database_restore_failed` |
|
||||
| migration/12 类表/附件恢复与关联验证 | 未执行 |
|
||||
| runtime 清理 | `PASS`,复核目录残留 `0` |
|
||||
| 一次性 PostgreSQL 容器清理 | `PASS`,复核残留 `0` |
|
||||
| 临时 root 入口与库清理 | `PASS`,复核残留 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
| 腾讯云生产公网 `/ready`(演练后) | `PASS`,构建短标识仍为 `7b86ffb3` |
|
||||
| 结论 | `NO_GO` |
|
||||
|
||||
Attempt 3 在数据库恢复命令返回非零后正确停止,未进入 migration、关键表、附件恢复或附件关联检查。当前输出不能区分 dump 列表校验失败、恢复命令失败、工具/参数合同不匹配或其他 runner 调用问题,因此该次 `NO_GO` 不能被改写为“备份不可恢复”,也不能据此给出数据恢复 RTO 或完整恢复结论。
|
||||
|
||||
本轮还暴露了独立的演练器可观测性缺陷:`restore-db.sh` 的详细输出仅写入权限 `0600` 的本轮私有日志,而失败清理随后删除该日志,最终只保留 `database_restore_failed` 聚合类别。安全清理是正确的,但失败关闭前必须先生成不含路径、凭据、业务内容或原始 SQL 的阶段化脱敏诊断;在该合同补齐并通过回归、重新审批前,不执行 Attempt 4,也不改选旧批次或放宽门禁。
|
||||
|
||||
措辞对齐说明:既有 Attempt 2 段落中的“临时副本只跳过目录 basename 断言”仅描述不把自建目录名当归档证据,不构成放宽。生效合同始终是副本 manifest run id 必须等于源侧已验证 run id,并且副本全部脱敏元数据与源侧首次校验结果完全一致。
|
||||
|
||||
## 2026-08-25 Attempt 3 后本地诊断修复
|
||||
|
||||
本节只追加本地 runner 与合成回归结果;未连接 ARM、未探测新批次、未执行 Attempt 4,也未修改腾讯云生产或 ARM 正式归档。
|
||||
|
||||
- runner 将数据库恢复拆为 `dump_list`、`target_confirmation`、`restore_apply` 三个阶段,只输出 `PASS/FAIL/NOT_RUN`、执行路径枚举、数字退出码、数字或 `UNKNOWN` 错误计数和稳定错误类别。
|
||||
- allowlist 诊断在 trap/finally 等价清理之前输出;原始 stderr 仍只存在于权限 `0600` 的本轮私有日志并随 runtime 删除。注入退出码 `23` 的回归证明:阶段诊断先输出,runtime/access 清理随后均为 `PASS`,调用方在清理后仍能读取数字退出码。
|
||||
- PostgreSQL 16 custom dump 直接驱动仓库 `scripts/restore-db.sh` 的 Docker stdin 路径成功;host-file 路径通过 PostgreSQL 16 工具执行同一真实脚本成功。没有在测试中重写等价恢复逻辑。
|
||||
- dump-list 失败、目标确认失败和 list/target 通过后 apply 失败三条负例均通过;后续阶段保持 `NOT_RUN`,apply 非零不会因对象错误计数较小而降级为成功。
|
||||
- 捕获诊断不含 fixture 凭据、连接串、SQL、临时路径或自由文本工具错误;既有合成成对备份/恢复回归通过,自动异机备份合同 `74` 项通过。
|
||||
|
||||
截至 Attempt 3,最新备份是否真的可恢复这个核心问题仍然没有答案。目前验证到的是演练器、恢复点身份门禁和每次失败后的安全收口,不是最新备份的数据可恢复性。Attempt 1 是发布后恢复点缺口;Attempt 2 是临时副本身份规则误用;Attempt 3 目前只能确定演练器可观测性不足,底层数据库恢复失败原因仍未知。三次均为 `NO_GO`,但不得把次数解释为“最新备份连续三次不可恢复”。
|
||||
|
||||
## 2026-08-25 Attempt 4 前最新批次只读探针
|
||||
|
||||
本节仅追加探针与本地 Git 静态核对结果;未复制、解包或恢复批次,未安装恢复 runner,未执行 Attempt 4。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 演练前公网 `/ready` | `PASS`,构建短标识 `7b86ffb3c9ef` |
|
||||
| ARM 最新批次探针 | `PASS` |
|
||||
| 最新批次构建短标识 | `7b86ffb3` |
|
||||
| 来源提交唯一解析 | `PASS` |
|
||||
| 来源与当前生产构建关系 | `SAME` |
|
||||
| 批次年龄 | `26652` 秒,约 7 小时 24 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
|
||||
| 来源/当前 migration 集合 | `65/65`,差异条目 `0` |
|
||||
| 仓库 HEAD 相对生产构建的应用代码漂移 | 文件数 `0` |
|
||||
| 探针 root 入口清理 | `PASS`,残留 `0` |
|
||||
| 普通用户探针暂存 | 残留 `0` |
|
||||
| ARM 两个监控 timer | `active` |
|
||||
|
||||
该批次满足 Attempt 4 的构建关系、新鲜度、run id 配对、产物完整性和 schema 集合前置门禁;该结论仍不是恢复授权,也不回答最新备份是否可恢复。
|
||||
|
||||
恢复 runner 的持久化限制不予接受:截至本节时,修复后的 runner 与八类本地回归仍位于不提交的个人 workspace,仓库尚无可由其他执行者独立复跑的 runner 合同门禁。Attempt 4 实际恢复前必须先把 runner 的稳定来源和对应合同回归纳入 `scripts/qa/`,由合同脚本机械验证候选 SHA-256、三阶段诊断、源/副本身份、真实 `restore-db.sh` 双路径和失败清理;未完成前不安装 Attempt 4 恢复入口。
|
||||
|
||||
## 2026-08-25 runner 持久化合同结果
|
||||
|
||||
本节仅追加本地跟踪文件与合同回归结果;未连接 ARM、未渲染现场候选、未执行 Attempt 4。
|
||||
|
||||
新增跟踪文件:
|
||||
|
||||
- `scripts/qa/offhost-shadow-restore-runner.template.sh`
|
||||
- `scripts/qa/validate-offhost-shadow-restore-attachments.py`
|
||||
- `scripts/qa/run-offhost-shadow-restore-runner-contract.sh`
|
||||
|
||||
| 合同类别 | 结果 |
|
||||
| --- | --- |
|
||||
| 私有参数渲染、确定性候选 SHA-256、shell 注入拒绝 | `PASS` |
|
||||
| 正式源身份与改名私有副本源侧重绑正例 | `PASS` |
|
||||
| 源 basename/run id 错配、副本篡改、副本 run id 与源不同负例 | `PASS` |
|
||||
| dynamic migration 集合指纹正/反例,无硬编码数量 | `PASS` |
|
||||
| 附件本地路径/大小/SHA-256 正例,路径逃逸与 S3 负例 | `PASS` |
|
||||
| 三阶段诊断、执行路径、两个退出码、后续 `NOT_RUN` | `PASS` |
|
||||
| 真实 `scripts/restore-db.sh` Docker stdin 与 PostgreSQL 16 host-file 路径 | `PASS/PASS` |
|
||||
| apply 非零失败关闭、诊断先于清理、清理后可读、无敏感自由文本 | `PASS` |
|
||||
|
||||
持久化模板不含远端地址、个人用户目录、远端账号、现场路径、实际容器/数据库名、生产构建值或 migration 指纹;这些值只能来自私有 JSON,并在候选写出前通过精确字段集、类型、字符集、绝对路径、shadow 命名和摘要格式校验。`check-sensitive-content.mjs` 对三个新增文件定向扫描结果为 `PASS`,扫描文件 `3`、finding `0`;本地测试容器残留 `0`。
|
||||
|
||||
Attempt 4 的 runner 产生过程已从个人 workspace 人工产物改为“跟踪模板 + 跟踪合同 + 私有参数确定性渲染 + 候选 SHA-256”。合同未通过不得生成或上传候选。该结果只补齐演练器可重复性,仍不构成最新备份可恢复性结论或 Attempt 4 恢复授权。
|
||||
|
||||
## 2026-08-25 私有渲染与阶段日志门禁补强
|
||||
|
||||
本节仅追加本地门禁修订与合成回归结果;未连接 ARM、未创建现场私有配置、未渲染或上传 Attempt 4 候选,也未执行实际恢复。
|
||||
|
||||
- `.gitignore` 已增加专用 `/.restore-drill-private/` 目录;合同分别注入仓库内未忽略的私有 JSON 和未忽略的候选输出路径,两者均在候选创建前按预期失败。ignored 目录内的合成输入/输出正例通过,测试文件在退出时清理。
|
||||
- 私有原始日志仍为 `0600` 且只在初始化时截断一次;PostgreSQL 容器启动、dump list、目标确认、restore apply 和附件恢复使用固定阶段标记追加。合同静态拒绝后续截断,最终 runtime 清理与只输出 allowlist 诊断的边界不变。
|
||||
- PostgreSQL 16 fixture 增加连续成功查询的稳定就绪门禁,避免镜像首次初始化临时服务关闭窗口造成合成回归假失败;真实 `scripts/restore-db.sh` 字节未修改,Docker stdin 和 host-file 两条路径仍直接执行仓库脚本并通过。
|
||||
- 本轮新增门禁回归全部 `PASS`;完成完整质量门禁并形成可追溯 Git 提交前,仍不得准备或上传 Attempt 4 候选。
|
||||
|
||||
完整本地门禁随后通过:runner 合同全部 `PASS`,既有隔离备份/恢复合成演练 `PASS`,自动异机备份合同 `74/74 PASS`;task validate、Trellis governance、process baseline、默认敏感扫描和三个新增文件定向敏感扫描均 `PASS`。文档审计错误 `0`,仅保留既有未登记文件 warning;`shellcheck` 在本机不可用,已以 Bash 语法、真实双路径集成回归和仓库合同覆盖。测试容器、本地私有渲染目录及候选残留均为 `0`。
|
||||
|
||||
## Attempt 4 环境差异预先裁决规则
|
||||
|
||||
本节在 Attempt 4 新批次探针和实际恢复之前追加,不能按执行结果事后改写。
|
||||
|
||||
- Attempt 2 的源/副本身份重绑和 Attempt 3 的三阶段诊断已经由提交 `5ec7db1` 下的可复跑合同机械把关;合同失败时不得生成或上传候选。
|
||||
- 若同一提交的合同、候选 SHA-256 和 ARM 安装前摘要均通过,但 Attempt 4 再次在恢复完成前暴露演练器侧失败,则默认 finding 升级为“本地合同环境与 ARM 实际执行环境存在系统性差异”,不再直接归为又一个孤立 runner 缺陷。
|
||||
- 后续调查必须先形成脱敏环境差异矩阵,至少比较 Docker/Podman 实现与版本、PostgreSQL 16 镜像摘要和工具版本、shell/coreutils/openssl/python 行为、文件系统与权限/SELinux/AppArmor、可用 PATH、恢复脚本及依赖摘要、网络模式和资源约束。未取得差异证据前,不继续逐点放宽或修补 runner。
|
||||
- 该规则不把任何失败预判为 ARM 环境问题,也不改变备份内容判定。若阶段诊断明确证明 dump、数据库对象、migration 或附件内容不满足既有合同,仍按对应数据恢复 finding 如实分类。
|
||||
|
||||
## 2026-08-25 Attempt 4 执行前最终探针与候选
|
||||
|
||||
本节仅追加最新批次只读探针、候选本地渲染与合同结果;未上传或安装恢复候选,未执行 Attempt 4 实际恢复,未写腾讯云生产或 ARM 正式归档。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| ARM 最新正式批次探针 | `PASS`;动态重选后仍为同一最新批次 |
|
||||
| 最新批次构建短标识 | `7b86ffb3` |
|
||||
| 来源与当前生产构建关系 | `SAME` |
|
||||
| 批次年龄 | `38572` 秒,约 10 小时 43 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
|
||||
| 探针临时 root 入口/sudoers 残留 | `0/0` |
|
||||
| 普通用户探针暂存 | 再次通过 SHA 清单后逐一删除,残留 `0` |
|
||||
| ARM dead-man/健康 timer | `active/active` |
|
||||
| 来源/当前 migration 目录 | `65/65`,差异条目 `0` |
|
||||
|
||||
候选由提交 `5ec7db1` 的 `scripts/qa/offhost-shadow-restore-runner.template.sh` 和 ignored 私有 JSON 确定性渲染。私有 JSON 权限为 `0600`、精确 20 个字段,runner 权限为 `0700`,输入与输出均命中仓库根锚定 ignore。两次独立渲染的字节和 SHA-256 完全一致;候选 SHA-256 为 `81f4eca2cf173196e1a57b781d28629412de7b7fe1a09b001b02d2025950391c`,合同报告值与本地 `sha256sum` 重算一致。
|
||||
|
||||
完整 runner 合同再次全部 `PASS`:源/副本身份、三条身份负例、dynamic migration、附件安全、五阶段追加日志、失败先诊断后清理、真实 `restore-db.sh` Docker stdin/host-file 双路径、退出码和敏感输出边界均通过。私有候选包共 6 个文件(runner、附件校验器、两份现有恢复脚本、sudoers、SHA 清单),包内 SHA 清单复核 `PASS`;runner 钉住的三份依赖摘要全部匹配,sudoers 固定命令与 runner 自身路径一致,`scripts/restore-db.sh` 相对生产构建仍无字节漂移,本地测试容器残留 `0`。
|
||||
|
||||
准备过程有一次安全失败:首次来源 migration 枚举误把 `migration_lock.toml` 当作 migration 名称,现场显示 `66/65`。该错误发生在候选渲染前,没有产生候选;随后改为只读取来源提交 `server/prisma/migrations` 树下的目录对象,并与当前工作树目录集合独立比较,结果为 `65/65`、差异 `0` 后才生成私有配置。Attempt 4 若获批准,安装前仍须在 ARM 复核上述候选包 SHA 清单;批次年龄和最新批次身份也须以实际执行开始时为准,超过 24 小时或最新批次变化时重新探针,不沿用本节结论。
|
||||
|
||||
## Attempt 4:目标数据库确认阶段失败关闭
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
本节仅追加 Attempt 4 原始脱敏结果,不改写前置探针、候选合同或环境差异预先裁决规则。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| ARM 安装前候选包 SHA 清单 | `PASS` |
|
||||
| 正式批次与私有副本前置门禁 | 已通过,否则不会进入 `restore_database` |
|
||||
| dump list | `PASS`,退出码 `0` |
|
||||
| target confirmation | `FAIL` |
|
||||
| 失败阶段/类别 | `restore_database` / `restore_target_confirmation_failed` |
|
||||
| 执行路径 | `DOCKER_STDIN` |
|
||||
| restore apply | `NOT_RUN` |
|
||||
| restore 脚本/真实 pg_restore 退出码 | `NOT_RUN/NOT_RUN` |
|
||||
| pg_restore 错误计数 | `NOT_RUN` |
|
||||
| 稳定错误类别 | `TARGET_CONFIRMATION_FAILED` |
|
||||
| migration/12 类关键表 | `NOT_RUN/NOT_RUN` |
|
||||
| 附件恢复与业务关联 | `NOT_RUN` |
|
||||
| runtime/临时 root 入口清理 | `PASS/PASS` |
|
||||
| 普通用户候选暂存 | `PASS`,残留 `0` |
|
||||
| 结论 | `NO_GO` |
|
||||
|
||||
`dump_list=PASS` 只证明最新 custom dump 可由 ARM 的 PostgreSQL 16 工具读取目录;`target_confirmation=FAIL` 发生在实际恢复调用之前,因此本次没有数据库数据写入、migration 查询、关键表读取、附件解包或附件关联校验。数据恢复 RTO 未形成,实际执行时的精确 RPO 秒数未被失败输出保留;只能确认 runner 已在创建 runtime 前通过不超过 24 小时的硬门禁,不能用前置探针年龄冒充执行时精确 RPO。
|
||||
|
||||
本轮输出没有分别保留 target query 的退出码和“查询成功但数据库名不等”布尔值,现有证据尚不能区分连接/认证/工具调用失败与目标名称比较失败。正式归档在 runner 中只有读取和复制路径,未观察到归档写入;但失败发生在末尾源批次复校验之前,因此“正式归档零修改”仍需后续只读摘要复核,不以代码静态结论替代现场证据。演练后生产 `/ready` 也须另行补充,不能从 ARM timer 或清理 PASS 推断。
|
||||
|
||||
按 Attempt 4 开始前已提交的裁决规则,本次先登记为 `local_contract_vs_arm_environment_difference_investigation`,不是新的孤立 runner 缺陷,也不是备份不可恢复证据。下一步仅允许建立脱敏环境差异矩阵和补齐生产/归档只读复核;在差异证据、修订设计和新批准之前,不修改 runner、不创建 Attempt 5、不复用本批次盲重试。
|
||||
|
||||
## Attempt 4 失败后的只读环境差异调查
|
||||
|
||||
本节仅追加失败后的只读 ARM 信息与本地合成复现;未在 ARM 启动调查容器、未读取业务数据、未修改 runner 或正式归档。
|
||||
|
||||
| 维度 | 本地合同环境 | ARM 实际环境 | 判定 |
|
||||
| --- | --- | --- | --- |
|
||||
| CPU/容器架构 | `x86_64` / `linux/amd64` | `aarch64` / `linux/arm64` | 不同 |
|
||||
| Docker client/server | `25.0.4/25.0.4` | `28.0.2/28.0.2` | 不同 |
|
||||
| PostgreSQL 16 镜像实例摘要前缀 | `fff3594bf464` | `c05eced0bdb4` | 多架构实例不同 |
|
||||
| 存储/cgroup/rootless | `overlay2/systemd/no` | `overlay2/systemd/no` | 相同 |
|
||||
| Bash/Python/OpenSSL/coreutils | `5.1.4/3.9.2/1.1.1w/8.32` | `5.1.4/3.9.2/1.1.1w/8.32` | 相同 |
|
||||
| AppArmor/SELinux | `Y/unavailable` | `Y/unavailable` | 相同 |
|
||||
| 工作文件系统 | `ext2/ext3` 类 | `ext2/ext3` 类 | 相同 |
|
||||
| 必需命令 | 合同执行通过 | `PASS` | 无缺失证据 |
|
||||
|
||||
差异矩阵本身不能证明 Docker 28 或 arm64 有缺陷。真正收敛根因的是启动时序覆盖不一致:tracked runner 在启动 PostgreSQL 后以第一次 `pg_isready` 成功结束等待;本地合同夹具曾真实出现“初始化临时服务正在关闭”的失败,之后夹具改成连续两次真实 `SELECT 1` 成功才继续,但 runner 的等待实现没有同步修改。于是合同环境稳定化后绕过了 runner 的竞态,而 ARM 仍执行首次 `pg_isready` 路径。
|
||||
|
||||
失败序列与该机制一致:dump list 不连接目标数据库,因此可在 PostgreSQL 初始化服务切换窗口中 `PASS/0`;紧随其后的 target query 需要真实连接,命中关闭/重启窗口即进入 `TARGET_CONFIRMATION_FAILED`,actual restore 尚未调用。本地使用相同镜像配置、相同随机凭据和 runner 中完全相同的 target query,在连续两次真实查询确认稳定后得到退出码 `0`、数据库名精确匹配、stderr 为空;本地测试容器随后清理为残留 `0`。
|
||||
|
||||
当前高置信度根因类别为 `postgres_initialization_readiness_race`,上位 finding 仍为开跑前定义的 `local_contract_vs_arm_environment_difference_investigation`。系统性差异具体表现为:合同夹具和 runner 没有复用同一稳定就绪路径,架构/容器版本造成的时序差异把该覆盖缺口暴露在 ARM。修复方向应是 runner 与合同共同执行“连续成功的真实目标数据库查询”并保留 target query 数字退出码与名称匹配布尔值,而不是为 ARM 增加一次固定 sleep、放宽目标确认或再次只修测试夹具。该修复和任何 Attempt 5 均须先更新设计、补可复跑负例并重新批准。
|
||||
|
||||
失败后只读残留复核:一次性容器、runtime、root 入口、恢复库目录、sudoers 和普通用户暂存均为 `0`;ARM dead-man 与公网健康 timer 均为 `active`。演练后生产 `/ready` 与正式归档摘要在此时仍待独立只读补证。
|
||||
|
||||
## Attempt 4 后正式归档只读复核
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
本节追加 Attempt 4 后正式归档只读探针结果,不回写前置探针或 Attempt 4 原始失败记录。探针不复制、不解包、不恢复、不启动数据库容器,也不修改正式归档。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 只读探针 | `PASS`,退出码 `0` |
|
||||
| 最新批次构建短标识 | `7b86ffb3`,与当前生产构建相同 |
|
||||
| 探针时批次年龄 | `42007` 秒,约 11 小时 40 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件产物完整性 | 大小与 SHA-256 聚合 `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
|
||||
| 探针临时访问清理 | `PASS` |
|
||||
| 探针入口/暂存残留 | `0/0` |
|
||||
|
||||
Attempt 4 前后探针的最新构建短标识、run id 配对、产物完整性和 manifest 脱敏摘要前缀一致,后置探针仍在 24 小时内通过。由此正式归档的内容级零修改已取得现场只读补证;该结论仅覆盖正式归档,不替代恢复成功证明,也不回答最新备份是否可恢复。
|
||||
|
||||
腾讯云生产演练后 `/ready` 仍未独立补证。ARM 的 timer active、清理 PASS 和归档探针均不能推导生产在线状态;在取得 `/ready` 正常状态及 12 位构建前缀前,AC2 保持未满足,证据不得表述为生产前后均正常。
|
||||
|
||||
## Attempt 4 后生产只读补证与本地稳定就绪修复
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
执行方沿既有审计窄路径完成腾讯云生产演练后 `/ready` 只读核验:状态 `PASS`,12 位构建前缀 `7b86ffb3c9ef`,与演练前记录一致。地址和原始响应未写入本证据;该读取没有登录、写库、重启、切换或配置修改。至此 Attempt 4 的生产前后 `/ready` 均正常已补证,但不改变本轮 restore apply 未执行和最新备份可恢复性仍未知的结论。
|
||||
|
||||
用户随后批准的范围仅为本地 runner 修复与回归,不连接 ARM、不执行 Attempt 5。修复把 PostgreSQL 就绪从默认 Unix socket 的首次 `pg_isready` 改为容器内 TCP 回环 `127.0.0.1` 的真实 `SELECT current_database()`:initdb 临时服务器不监听 TCP,因此该判据结构性排除临时服务器;连续两次成功和最多 60 次有界重试只作为额外稳定保险。目标名称成功但不匹配立即失败,查询非零重置 streak;实际 `restore-db.sh` 仍走原路径且字节不改。
|
||||
|
||||
合同夹具与 runner 分叉审计结果:
|
||||
|
||||
| 覆盖对象 | 合同机制 | 是否存在等价逻辑分叉 |
|
||||
| --- | --- | --- |
|
||||
| 私有配置渲染与候选 SHA | 直接使用跟踪模板和合同内唯一渲染器 | 否 |
|
||||
| 正式源/临时副本身份 | 从 rendered runner 提取 `validate_batch` 同一实现 | 否 |
|
||||
| PostgreSQL 目标就绪 | 从 rendered runner 提取同一 TCP 目标确认函数;真实 PostgreSQL 16 fixture 也调用该函数 | 原分叉已消除 |
|
||||
| DB restore Docker stdin/host-file | 直接执行仓库 `scripts/restore-db.sh` | 否 |
|
||||
| 附件引用安全 | 直接执行跟踪附件校验器 | 否 |
|
||||
| 失败诊断与 finally 清理 | 从 rendered runner 提取初始化和 finalization 同一实现 | 否 |
|
||||
| migration 指纹 | 对 rendered 值、目录集合变化和禁止硬编码做静态/合成断言 | 无等价重写;真实恢复集合仍待远端演练 |
|
||||
| 12 类表、storage 与完整编排 | 本地只做组件合同,不执行含真实备份的完整 runner | 明确覆盖限制,不能推断远端成功 |
|
||||
|
||||
就绪回归全部 `PASS`:socket 路径假定可用但 TCP 永久失败仍在第 60 次失败;首次查询失败后连续两次成功在第 3 次通过;一次成功后失败会清零 streak 并在第 4 次通过;目标名不匹配第 1 次立即失败;永久查询非零以 `TARGET_QUERY_NONZERO` 失败。真实 PostgreSQL 16 fixture 使用 extracted rendered-runner 函数就绪后,`restore-db.sh` Docker stdin 与 host-file 两条路径均成功;dump-list、目标错配、apply 非零、诊断先于清理、敏感输出负例继续通过。本地测试容器残留 `0`。
|
||||
|
||||
完整复跑结果:runner 合同全部 `PASS`,既有成对备份/恢复演练 `PASS`,自动异机备份合同 `74/74 PASS`;task validate、Trellis governance、process baseline、默认/定向敏感扫描和 `git diff --check` 均通过。文档审计错误 `0`,仅保留既有未登记文件 warning;`scripts/restore-db.sh` 相对生产构建无字节漂移,测试容器和生成缓存残留均为 `0`。
|
||||
|
||||
本地合同通过只证明演练器修复与组件路径,不回答最新正式备份是否可恢复。下一步仍须先提交可追溯变更,再动态选择最新正式批次、执行只读身份探针并重新渲染候选;Attempt 5 实际恢复必须另行批准。
|
||||
|
||||
## Attempt 5 前最新正式批次只读身份探针
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
本节仅追加用户批准的最新正式批次只读探针与本地仓库关系核对;未执行恢复、未复制或解包正式归档、未启动 Docker、未读取数据库或附件内容。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 探针结果 | `PASS`;退出码未单独输出 |
|
||||
| 最新批次构建短标识 | `7b86ffb3` |
|
||||
| 来源与当前生产构建关系 | `SAME` |
|
||||
| 探针时批次年龄 | `64625` 秒,约 17 小时 57 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
|
||||
| 来源/当前 migration 目录 | `65/65`,差异 `0` |
|
||||
| 仓库 HEAD 相对生产构建的应用代码漂移 | 文件数 `0` |
|
||||
| 探针 root 入口/普通用户暂存残留 | `0/0` |
|
||||
| ARM dead-man/健康 timer | `active/active` |
|
||||
|
||||
timer 状态只采用已验证私有配置中的实际 unit 名只读取得;一次使用猜测名称得到的无效读数已当场撤销,未进入证据结论。探针结果表明该最新批次继续满足构建、新鲜度、身份和完整性前置门禁,但仍不是恢复成功证明。
|
||||
|
||||
Attempt 5 最终候选尚未渲染。原因是用户提出的 RTO 分阶段记录已进入设计、但 runner 实现尚未另行批准;若先渲染,后续计时代码会改变候选字节和 SHA-256。必须先裁决并完成 RTO 八阶段本地合同,再从跟踪模板重新确定性渲染候选并核对 SHA-256,实际恢复仍须单独批准。
|
||||
|
||||
## Attempt 5 前八阶段 RTO 本地合同
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
用户随后批准的范围仅为本地实现八阶段 RTO 计时并执行合同回归;本阶段未连接 ARM、未渲染现场候选、未生成 Attempt 5 候选 SHA-256,也未执行实际恢复。此前探针批次的 `64625` 秒新鲜度只属于探针时点,后续不得复用,实际恢复前必须重新选择最新正式批次并重跑只读身份门禁。
|
||||
|
||||
runner 现在以连续边界依次记录 `batch_copy`、`postgres_container_start`、`dump_list`、`target_confirmation`、`restore_apply`、`database_verify`、`storage_attachment_verify` 和 `final_integrity`。同一个时间点关闭前一段并开启后一段;成功路径八段均为非负整数且精确加总为唯一的 `data_restore_rto_seconds`。失败路径先结算当前阶段,保留已完成和当前阶段的数字,后续阶段为 `NOT_RUN`,然后才清理私有 runtime。最长 60 次 TCP 目标确认及其 1 秒间隔只位于 `target_confirmation` 边界内。
|
||||
|
||||
可复跑合同直接驱动 rendered runner 的同一计时函数。确定性成功夹具得到八段 `3/5/2/11/7/13/17/3` 秒,总计 `61` 秒且总字段只输出一次;目标确认失败夹具保留 `2/4/1/9` 秒、后四段 `NOT_RUN`、部分总计 `16` 秒,并证明计时输出位于 runtime cleanup 之前;restore apply 失败夹具记录当前段 `12` 秒、后续三段 `NOT_RUN`、部分总计 `28` 秒。捕获输出不含路径、连接串、凭据或 SQL。
|
||||
|
||||
完整本地门禁结果:runner 合同全部 `PASS`;真实 PostgreSQL 16 fixture 和未修改的 `scripts/restore-db.sh` Docker stdin/host-file 双路径继续通过;既有成对备份/恢复演练 `PASS`;自动异机备份合同 `74/74 PASS`;task validate、Trellis governance、process baseline、docs registry、敏感扫描和 `git diff --check` 通过。文档审计错误 `0`,保留既有未登记文件 warning;本地测试容器残留 `0`,`scripts/restore-db.sh` 相对生产构建仍无字节漂移。
|
||||
|
||||
Attempt 5 前增强停止线从本节起生效:八阶段计时完成并通过合同后,下一份满足 24 小时门禁的新鲜批次直接进入最终候选渲染、SHA-256 核对、只读探针和实际恢复审批。此后发现的改进项,除非会导致 Attempt 5 失败或产生错误结论,否则只登记为后续任务,不再作为 Attempt 5 前置增强插队。本地合同通过仍只证明演练器合同,不回答最新正式备份是否可恢复。
|
||||
|
||||
## Attempt 5 最终候选与执行前只读探针
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
本阶段只执行用户批准的三件事:重新选择最新正式批次、从跟踪模板渲染最终候选并核对 SHA-256、运行一次自清理只读身份探针。未复制或解包正式归档、未启动恢复容器、未执行数据库或附件恢复,也未连接腾讯云生产。
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| runner 跟踪提交 | `0b94b97` |
|
||||
| 两次确定性渲染 | 字节一致,完整合同 `PASS` |
|
||||
| 最终候选 SHA-256 | `7904052aa5a8c31d54734bc2fbf2d9caf2dac8b0116135119519f8467b3dae27` |
|
||||
| 探针结果 | `PASS` / exit `0` |
|
||||
| 最新批次构建短标识 | `7b86ffb3`,与当前生产 `SAME` |
|
||||
| 探针时批次年龄 | `68889` 秒,约 19 小时 08 分,`within_24h=yes` |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ee1f97a5b42d` |
|
||||
| 来源/当前 migration | `65/65`,差异 `0`,候选指纹匹配 |
|
||||
| 应用代码/restore-db 字节漂移 | `0/0` |
|
||||
| 探针 access/entry/staging 残留 | `PASS/0/0` |
|
||||
| 本地合同容器残留 | `0` |
|
||||
|
||||
探针时距 24 小时门禁约剩 `17511` 秒,即约 4 小时 52 分。该余量不被静态批准延长;Attempt 5 runner 在实际开始时仍会动态选择最新正式批次并重新校验构建、run id、产物 SHA-256 和年龄。若执行被推迟到候选超龄且尚无更新批次,runner 必须在复制前失败关闭,不能沿用本节探针结论。
|
||||
|
||||
候选钉住的是 `EXPECTED_BUILD_PREFIX` 和 `EXPECTED_MIGRATION_SET_SHA`,不是本次探针批次的 source run id。runner 会在 ARM 上于实际执行时重新选择当时最新正式批次,再对实际批次执行构建前缀、目录/run id、三文件集合、大小、SHA-256、RPO 和恢复后 migration 集合门禁。如果探针后有更新批次原子落地,实际恢复输入可以合法切换到更新批次;最终证据必须记录实际批次的脱敏身份,并明确它与本次探针批次是否一致,不能仅因候选 SHA-256 不变就默认恢复了探针批次。
|
||||
|
||||
至此 Attempt 5 的前置批次身份、演练器合同、八阶段 RTO 和失败诊断均已具备;最新正式备份是否实际可恢复仍未得到答案,必须由另行批准的 Attempt 5 隔离恢复给出结论。
|
||||
|
||||
## Attempt 5 实际结果与安装器路径根因
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
用户明确批准 Attempt 5 后,执行方在 ARM 安装前再次核对候选 SHA-256,完整值与已批准候选一致。紧邻执行前的只读探针在演练调用同一秒观测到最新正式批次年龄 `70444` 秒,来源构建与当前生产相同,run id/目录配对、DB/附件产物完整性、source digest 和 24 小时门禁均为 `PASS`。探针反推的批次创建时刻已作为操作侧脱敏时间证据保留。
|
||||
|
||||
runner 随后在 `entry_contract` 阶段以 `restore_library_missing` 失败关闭:`dump_list`、目标确认和 restore apply 均为 `NOT_RUN`,八个 RTO 阶段与总计也全部为 `NOT_RUN`,最终结果原样保留为 `NO_GO`。失败发生在 runner 选择实际恢复批次之前,因此本轮不存在可对外记录的“实际恢复批次”,也不能用紧邻探针批次代替实际恢复输入。正式归档复制、PostgreSQL 容器启动、数据库恢复、migration/12 类表核对、附件恢复与关联验证均未开始;这次结果没有证明最新备份可恢复,也没有证明备份不可恢复。
|
||||
|
||||
本次 finding 分类为 `installer_path_mismatch`,属于演练安装器缺陷。已批准 runner 的依赖合同指向固定 `libexec` 目录,本轮一次性安装器却把三个依赖安装到名称相似但不同的 `lib` 目录。runner 因而在正确位置找不到依赖并按入口合同失败关闭;其 `temporary_access_cleanup=PASS` 清理的是合同内目标,而错误目录未被该清理函数命中,这解释了初始 `attempt5_entry_residue=1` 与 runner 自报清理通过并存的原因。
|
||||
|
||||
对错误目录的定向只读诊断得到:`restore-db.sh`、`restore-storage.sh` 和附件校验器均存在、均为 root 拥有的普通文件,三者 SHA-256 脱敏前缀与本地安装包一致,额外条目数为 `0`。在目标身份和文件集合全部明确后,仅对该错误安装目录执行精确清理,结果 `runner_lib_cleanup=PASS`;该动作未使用宽泛目录匹配,也未触碰正式归档。
|
||||
|
||||
诊断脚本的自删除另外暴露出一个安装器层问题:脚本在 sudo 上下文中以 `$HOME` 推断普通用户暂存路径,实际解析为 root 家目录,因目标不存在而报 `unlink` 失败。这不影响错误依赖目录已清理的结论;随后由非 root 外层对普通用户暂存中的已知诊断脚本做精确删除。后续安装器不得在 sudo 脚本中使用 `$HOME` 推断调用用户路径,应由安装器预先固定并验证普通用户暂存目标,或由非 root 外层负责自清理。
|
||||
|
||||
演练后腾讯云生产 `/ready` 由既有审计窄路径只读取得 `PASS`,12 位构建前缀仍为 `7b86ffb3c9ef`,与演练前一致。Attempt 5 未进入正式归档选择和复制路径,也未启动容器或恢复写入,因此腾讯云生产与正式归档未被本次失败修改。
|
||||
|
||||
在任何下一次实际恢复前仍需做一次最终只读零残留复核,范围为:runner 入口、合同内 `libexec`、错误 `lib`、runner/probe sudoers 及 candidate、probe 入口、普通用户安装器/候选/诊断暂存、Docker 容器和 runtime,并确认 dead-man 与健康 timer 仍为 `active/active`。在该复核完成前,不把 Attempt 5 表述为“全部临时对象零残留”。
|
||||
|
||||
最小修订方案只涉及 ignored/workspace 安装器:将三项依赖的安装目标从错误 `lib` 对齐到 runner 已批准的 `libexec`,并修正普通用户暂存脚本的清理所有权;不修改已批准 runner,不重新渲染候选,候选 SHA-256 可保持不变。该安装器修订、本地校验、最新批次只读探针和任何再次实际恢复均需按既有批准边界分别推进。核心问题——最新正式备份能否完成数据库与附件隔离恢复——在 Attempt 5 后仍无答案。
|
||||
|
||||
## Attempt 5 最终零残留复核与安装器最小修订
|
||||
|
||||
日期:2026-08-25
|
||||
|
||||
用户批准后,执行方沿既有 ARM 窄路径运行固定目标的最终复核入口。该入口只读取本轮已知入口、依赖目录、sudoers、暂存、容器/runtime 和两个监控 timer 的聚合状态,不读取正式归档或业务数据,不执行恢复。结果如下:
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| runner/probe 入口、sudoers 与 candidate 残留 | `0` |
|
||||
| runner 合同内 `libexec` 残留 | `0` |
|
||||
| 安装器误用的 `lib` 残留 | `0` |
|
||||
| 普通用户安装器/候选/诊断暂存残留 | `0` |
|
||||
| 本轮容器残留 | `0` |
|
||||
| 本轮 runtime 残留 | `0` |
|
||||
| dead-man/健康 timer | `active/active` |
|
||||
| 最终复核 | `PASS` |
|
||||
| 复核脚本自身清理 | `PASS` |
|
||||
|
||||
至此 Attempt 5 初始 `entry_residue=1` 已由定向诊断、精确清理和独立最终复核闭环为全部已知临时对象残留 `0`。该清理结论不改变 Attempt 5 的 `NO_GO`,也不补造任何未运行的 RTO、数据库或附件恢复证据。
|
||||
|
||||
已批准的本地最小修订只改变 ignored/workspace 安装器,不进入 Git 历史:三项依赖安装目标由错误 `lib` 对齐到已批准 runner 中同一 `libexec`,静态检查确认安装器与 rendered runner 的目录类别一致;诊断脚本以自身调用路径作为自清理目标,并在删除前验证固定 basename、普通文件且非链接,不再读取 sudo `$HOME`。三个私有脚本通过 Bash 语法检查和 Git ignore 守卫;已批准 runner、附件校验器、恢复脚本和候选字节均未修改,候选 SHA-256 不变。
|
||||
|
||||
本地完整复核结果:影子恢复 runner 合同全部 `PASS`,真实 PostgreSQL 16 fixture 的 Docker stdin/host-file 路径继续通过,合成成对备份/恢复演练 `PASS`,自动异机备份合同 `74/74 PASS`;task validate、Trellis governance、process baseline、敏感扫描和 `git diff --check` 通过。文档审计错误 `0`,只保留既有未登记文件 warning;`scripts/restore-db.sh` 相对生产构建字节漂移 `0`,本地测试容器残留 `0`。
|
||||
|
||||
任何再次实际恢复仍须重新选择满足 24 小时门禁的新鲜批次、重新执行只读身份探针并取得独立批准。本节只闭环 Attempt 5 的环境清理和安装器路径缺陷,不回答最新正式备份是否可恢复。
|
||||
|
||||
## 08-27 最新正式批次只读身份探针
|
||||
|
||||
日期:2026-08-27
|
||||
|
||||
本节只追加用户批准的最新正式批次只读探针。未复制、解包或恢复正式归档,未启动 Docker,未修改腾讯云生产、ARM 正式归档、timer 或企业微信。
|
||||
|
||||
探针准备前完成了安装器共享路径静态核对:runner 的 `SELF_PATH`、`LIB_DIR`、`SUDOERS_PATH` 与安装器目标精确一致,probe 的 `SELF_PATH`、`SUDOERS_PATH` 与探针安装器精确一致;`WORK_ROOT` 只由 runner 持有,安装器没有第二份编码;sudoers candidate 由最终 sudoers 路径推导。上述比较均位于首次 `sudo install` 之前,失败即不产生特权写入。
|
||||
|
||||
第一次上传准备因本地编排器错误截取 basename,三个文件以截短名称进入普通用户暂存;远端 SHA-256 门禁立即失败。该失败发生在 sudo 和探针执行之前,未安装 root 入口、未读取归档。确认暂存目录恰含三个预期的截短普通文件后,逐个精确删除并移除本轮暂存和安装器,再使用真实 basename 重新上传;重传包的两项 SHA-256、Bash 语法和三文件集合均通过。该 finding 属于 `local_probe_upload_orchestration_defect`,未影响 probe/runner 字节或 ARM 正式状态。
|
||||
|
||||
现场探针结果:
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| 探针执行 | `PASS` / exit `0` |
|
||||
| 最新批次构建短标识 | `7b86ffb3`,与当前生产相同 |
|
||||
| 最新批次年龄 | `124137` 秒,约 34 小时 29 分 |
|
||||
| 24 小时门禁 | `FAIL`;超出 `37737` 秒,约 10 小时 29 分 |
|
||||
| run id 与目录配对 | `PASS` |
|
||||
| DB/附件大小与 SHA-256 | `PASS` |
|
||||
| source image digest | 存在 |
|
||||
| manifest 脱敏摘要前缀 | `ede8a77d9912` |
|
||||
| 探针合同内临时访问清理 | `PASS` |
|
||||
| 探针入口/普通用户暂存残留 | `0/0` |
|
||||
|
||||
`probe_result=PASS` 只表示只读探针完整执行并成功校验所见批次,不等于恢复前置门禁通过。按 PRD 的 24 小时硬门禁,本次结论为 `NO_GO/stale_latest_batch`:不得执行实际恢复,也不得改选更旧批次掩盖最新恢复点不新鲜。构建、身份和产物完整性通过说明当前证据不指向归档损坏;但仅凭本次探针不能区分源端自动备份未产出、传输/finalize 失败或调度时点变化。
|
||||
|
||||
下一步须另行批准只读调查生产自动备份最近运行状态、ARM receiver 最新原子归档时刻以及 dead-man/健康 timer 状态。该调查不得手工触发备份或修改 timer。只有新的正式批次满足 24 小时门禁并重新通过身份探针后,才能再次申请实际隔离恢复批准;既有恢复授权不延续。
|
||||
|
||||
## 08-27 自动备份链与 dead-man 只读调查
|
||||
|
||||
日期:2026-08-27
|
||||
|
||||
用户批准的调查只读取生产自动备份 systemd 聚合状态、ARM receiver 原子归档/inbox 聚合状态、dead-man 状态和两端文件系统容量。未触发备份、未修改 timer、未执行恢复、未改正式归档、未发送测试告警,也未输出远端身份、现场路径、原始 JSON、journal、凭据或完整 run id。
|
||||
|
||||
生产侧结果:daily timer 与 watchdog timer 均为 `active`,最近分别在当日计划时点触发;对应 service 最近一次均为 `failed/result=exit-code/exit=1`。因此“timer 未调度”被排除,失败位于两项服务共用的备份运行合同或其后续路径。批准的部署账号不具备 sudo,也不能穿越 root-owned 状态/本地备份目录;调查明确记录 `ACCESS_DENIED`,没有把无权读取改写成状态文件或本地批次不存在。该权限边界使生产侧原始失败 phase 保持未知,不据此猜测具体 stderr。
|
||||
|
||||
ARM 侧结果:dead-man timer 与公网健康 timer 均为 `active`;dead-man service 最近一次按失败状态退出。实际 dead-man 阈值为显式参数 `93600` 秒(26 小时)。`last-deadman` 为 `FAIL_OFFHOST_DEADMAN/archive_stale/findingCount=1`,alert state 同为 `FAIL_OFFHOST_DEADMAN/archive_stale`。最近 72 小时 journal 仅做 allowlist 聚合,得到 `sent=true` 一次、`sent=false` 两次,最近一次为 `sent=true`。该次 dead-man timer 触发锚点为 2026-08-27 06:30:06(Asia/Shanghai),alert state 在同轮随即更新,可作为本次发送调用时刻。恢复演练探针在同日稍后首先促成人工响应,但当时未独立采集“人员实际知晓”的精确墙钟时刻,因此本轮不能伪造精确响应延迟,只能登记为“发送调用早于人工响应、精确差值未测”。后续事件任务必须同时记录 `sent=true` 与首次确认时刻,直接计算真实响应延迟。
|
||||
|
||||
检测侧结论为正面:真实超龄被正确检测,告警 helper 至少完成了一次发送调用,且相同失败的 `sent=false` 属于正常去抖;08-23 去抖改造没有造成检测或发送调用盲区。响应侧仍有 finding:本次备份中断最终由恢复演练探针触发处置,而不是由既有告警形成及时响应。证据不证明具体人员已阅读消息,后续应调查群消息送达、可见性、责任人确认和升级流程,不能把检测调用成功等同于闭环响应。
|
||||
|
||||
receiver 聚合结果:正式原子日归档共 7 个,最新归档在复核时年龄约 43 小时 40 分;inbox 有 2 个 `.partial`,正式归档事务临时对象为 0、inbox 其他对象为 0。两个 partial 均不满足“三个普通文件 + manifest/run 身份 + 产物字节数”的完整传输合同,完整合同数量为 0、不完整数量为 2;最新 partial 年龄约 19 小时 40 分,最旧 partial 约 31 天 10 小时。由此将当前事故分类为 `offhost_transfer_incomplete`:生产 daily 确实触发并在 ARM 留下未完成传输,不能归为源端完全未产出、timer 漏调度或“传输已经完整但 finalize 失败”。partial 的存在同时证明 rsync 已通过初始 SSH 认证、host key 校验和 receiver 授权并开始写入,因此密钥失效、authorized_keys 初始拒绝和 host key 初始拒绝不是本次首要候选;排查应收缩到传输中段的超时/网络中断、源端读取、目标端写入或会话/传输限制。最旧 partial 同时是历史清理/观测 finding,但本轮没有删除它。
|
||||
|
||||
当前只读字段还不足以在上述传输中段候选之间继续裁决。下一轮独立生产事件调查应比较两个 partial 的聚合字节大小/停止比例,读取 wrapper 私有诊断中的真实 rsync 退出码,并记录每轮传输持续时间:相近停止偏移或相近时长支持确定性限制,不同偏移/时长更支持网络或超时;service 的通用 exit `1` 不得替代 rsync 原始码。该调查仍只输出字节聚合、退出码和耗时,不输出文件名、路径或 stderr 原文。
|
||||
|
||||
容量必须与传输修复并行处理:生产侧相关路径位于同一可识别文件系统,使用率约 82%、可用约 10 GiB;ARM archive/inbox 文件系统使用率约 53%、可用约 89 GiB。现有证据仍不足以把这次 rsync 中断直接归因于磁盘不足,但自动备份会先在生产生成完整 dump 与 storage 归档,持续失败还会反复生成/保留本地产物;10 GiB 对该流程属于薄余量。若继续下降,故障可能从“传输中断”升级为“源端无法产出”,因此容量治理不是低优先级后续项,而是独立生产事件中的并行处置项。
|
||||
|
||||
调查器本身出现并安全关闭了两类本地缺陷:首次生产探针错误假设部署账号可 sudo,安装前即被权限拒绝;前两次 ARM 探针把 dead-man unit 写成近似但不存在的名称,均在状态读取前以合同不可读失败。每次 root 入口、sudoers candidate、普通用户暂存均为 0。最终版本从仓库 receiver 安装器机械提取并比较 unit basename,并将生产探针改为无提权只读执行;Bash/Python 语法、两种 ExecStart 解析 fixture 和敏感扫描通过。上述缺陷不改变生产、ARM、归档、timer 或告警状态,也没有伪造调查结果。
|
||||
|
||||
当前独立生产 finding 为:最新可用异机恢复点已超过 24 小时目标,自动备份传输链正在失败;恢复演练因此保持阻塞。该事件不再作为恢复演练的普通 finding 排队处理,建议另建 Trellis 生产事件任务承载传输/容量调查与修复,当前演练任务仅保留证据交叉引用和阻塞关系。后续优先顺序是另行批准修复生产到 ARM 的传输链并形成新的原子批次,确认自动备份/两端 dead-man 与 watchdog 恢复,再对新批次重新执行身份探针。手工补跑备份、修改网络/SSH/timer、清理 partial 或发送测试告警均不在本次只读批准内。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "isolated-backup-restore-drill",
|
||||
"name": "isolated-backup-restore-drill",
|
||||
"title": "隔离备份恢复演练",
|
||||
"description": "在不覆盖生产的隔离环境验证最新数据库与附件备份可恢复性,并记录RTO/RPO和脱敏证据。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "operations",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-23",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "2026-08-28:恢复演练保持 in_progress/P2,但被 P1 operations 事件 08-28-production-arm-backup-transfer-incident 阻塞。该事件负责生产到 ARM 传输链修复、生产容量处置、告警响应计时和连续两个自然调度周期验证;在新原子批次形成并重新通过 24 小时身份门禁前,本任务不得继续实际恢复。既有恢复候选 SHA 仍有效,无需因事件任务重新渲染。",
|
||||
"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,105 @@
|
||||
# 生产到 ARM 备份传输中断设计
|
||||
|
||||
## 设计结论
|
||||
|
||||
采用“先只读判因、再最小变更、最后两个自然周期验证”的事件处置路径。传输修复和容量处置属于同一个 P1 operations 任务,但每项远端写操作维持独立审批;恢复演练作为下游消费者保持阻塞,不参与本任务的根因修复。
|
||||
|
||||
## 证据链与判定矩阵
|
||||
|
||||
```text
|
||||
生产 daily timer 触发
|
||||
-> 本地成对批次生成
|
||||
-> rsync 建立 SSH/receiver 会话
|
||||
-> ARM inbox/<batch>.partial 写入
|
||||
-> rsync 完成
|
||||
-> receiver finalize 原子发布
|
||||
-> ARM dead-man + 生产 watchdog 判定新鲜
|
||||
```
|
||||
|
||||
当前证据已经到达 `.partial 写入`,所以初始 SSH 认证、host key 与 receiver 授权不是首要分支。后续只读调查按以下矩阵裁决:
|
||||
|
||||
| 证据 | 主要指向 | 保留边界 |
|
||||
| --- | --- | --- |
|
||||
| 两个 partial 停止偏移接近,失败时长也接近 | 确定性大小/读取/会话上限 | 结合 rsync 原始码确认,不直接改超时 |
|
||||
| 停止偏移差异明显,时长不收敛 | 网络或间歇超时 | 读取客户端/服务端安全状态,不抓原始 payload |
|
||||
| rsync `23` | 部分传输/源或目标文件错误 | 结合私有阶段类别,不输出 stderr |
|
||||
| rsync `30` | 超时 | 定位是哪一层超时,禁止无限放宽 |
|
||||
| rsync `12` | 协议/数据流错误 | 核对双方版本和会话中断证据 |
|
||||
| rsync `11` | 文件 I/O | 联查源读取、目标写入和容量 |
|
||||
| SSH/包装层 `255` 且已有 partial | 会话在启动后断开 | 不误判为初始认证失败 |
|
||||
| partial 满足完整三文件/manifest/字节合同 | finalize 未完成 | 当前现场为 0,不作为默认假设 |
|
||||
|
||||
## 只读采集合同
|
||||
|
||||
- 生产侧先用 manifest/三文件/身份/字节元数据枚举全部本地候选,只对最新一个元数据合格候选完整读取 database/storage 并计算 SHA-256;旧候选明确输出 `older_metadata_batch_hash_status=NOT_RUN`,不得为了计数连续读取数 GiB。最新候选只有通过完整 SHA-256 合同才输出 `complete_local_recovery_point=yes`;其哈希失败时不得现场回退扫描旧候选,而应保守标为“无已证明可用备份”。输出年龄、元数据合格数量和聚合大小,不输出 run id、文件名或路径。该结果先于传输根因分析,决定当前是“生产单点”还是“无已证明可用备份”。ARM 仍对正式归档执行完整哈希合同。
|
||||
- 对成功与失败路径分别输出本地完整批次、生成期临时对象和保留策略的聚合结果,明确 `retained_after_failure`、`pruned_after_success` 和连续失败累积趋势;不从单次目录计数猜测脚本语义。
|
||||
- partial 只输出每个匿名样本的总字节数、预期总字节数、安全比例和年龄;不输出名称、路径、run id 或 artifact 名称。
|
||||
- partial 停止偏移的“接近”必须同时满足相对差异不超过 `50000 ppm`(5%)和绝对差异不超过 `67108864` bytes(64 MiB),等价于以两者较小值作为上限。不得用两者较大值,否则小样本与零字节样本会被绝对阈值错误吞并为 `similar`。
|
||||
- 生产私有诊断只输出固定的 `transfer_exit_code`、`failure_category`、开始/结束 epoch 和持续秒数。若现有状态没有持久化真实 rsync 码或开始时刻,记录 `NOT_AVAILABLE`,需要本地设计可观测性修订并另行批准部署,不能现场改脚本。
|
||||
- 容量只输出 filesystem 使用率/可用 GiB、完整/不完整/临时批次数量和聚合字节,不列目录。
|
||||
- 采集器先探测账号能力;无 sudo 时不得尝试临时扩权。需要 root-owned 状态时先提交固定 allowlist 入口设计并单独审批。
|
||||
|
||||
若上述静态/历史只读证据仍无法收敛,停止追加远端读取项。下一候选步骤是一次单独审批的受控观测性重传:冻结一个已校验本地批次,只允许一次传输尝试,实时记录固定阶段、rsync/SSH 原始码、开始/结束 epoch 和聚合字节进度,设置有界超时且失败不重试。它是诊断性写操作,不属于第一阶段只读授权。
|
||||
|
||||
## 容量预算
|
||||
|
||||
生产修复前冻结以下聚合量:最近成功完整本地批次大小、一次备份生成期间的峰值临时增量、当前保留批次总量和文件系统可用量。最低通过线为:
|
||||
|
||||
```text
|
||||
available_before_schedule >= observed_full_batch_peak * 2
|
||||
```
|
||||
|
||||
其中 `observed_full_batch_peak` 包含 database dump、storage archive、manifest 和生成期临时开销;两倍口径提供一个新批次的实际需求和等量安全余量。若现场无法可靠测得生成期峰值,则使用“最近最大完整批次聚合大小 × 2 + 已知最大临时开销”的更保守口径。任何降低该门槛的决定须重新审阅 PRD;扩容优先于删除唯一恢复证据。
|
||||
|
||||
## 变更与回滚边界
|
||||
|
||||
- 根因明确后只设计一个最小变更集,例如有界传输超时、具体 I/O 修复、网络会话修复或容量扩展;不同根因不打包试错上线。
|
||||
- 修改 systemd/SSH/rsync 配置前保存脱敏旧值指纹和固定回滚命令;不输出配置正文。
|
||||
- 清理自动备份自有对象时必须先以固定模式、文件类型、所有者、时间和引用关系验证身份,逐个命中;不使用宽泛递归清理。
|
||||
- 手工补跑仅用于经单独批准的修复后快速验证,不计入两个自然周期;失败时保留新的脱敏 finding,不连续重试。
|
||||
|
||||
## 告警响应计时
|
||||
|
||||
从本任务开始使用两个独立 epoch:
|
||||
|
||||
- `alert_sent_epoch`:helper 返回 `sent=true` 所在调用时刻;
|
||||
- `human_ack_epoch`:指定责任人首次明确确认收到并开始处置的时刻。
|
||||
|
||||
`response_seconds = human_ack_epoch - alert_sent_epoch`。缺任一锚点即 `NOT_MEASURED`,不得用文件 mtime、对话时间或事后回忆替代。该指标只评价响应闭环,不改变 dead-man 的检测正确性。
|
||||
|
||||
## 验收状态机
|
||||
|
||||
```text
|
||||
PLANNING
|
||||
-> READ_ONLY_DIAGNOSIS_APPROVED
|
||||
-> ROOT_CAUSE_EVIDENCED
|
||||
-> CHANGE_PLAN_APPROVED
|
||||
-> FIX_APPLIED
|
||||
-> FIRST_ATOMIC_BATCH_PASS
|
||||
-> NATURAL_CYCLE_1_PASS
|
||||
-> NATURAL_CYCLE_2_PASS
|
||||
-> INCIDENT_GO
|
||||
```
|
||||
|
||||
任何一步失败都停在当前状态并保留证据。`FIRST_ATOMIC_BATCH_PASS` 形成一个满足 24 小时门禁的新鲜原子批次后,即可解除对恢复演练的事件阻塞;恢复演练仍需自己的身份探针和实际恢复审批。P1 事件本身只有到 `NATURAL_CYCLE_2_PASS` 才能结案,两条线不互相等待。
|
||||
|
||||
## 过渡恢复点决策
|
||||
|
||||
- `complete_local_recovery_point=yes`:若下个自然周期在可接受窗口内且容量预算允许,默认 `wait_natural`;仍可基于风险窗口单独申请手工补跑,但不是自动动作。
|
||||
- `complete_local_recovery_point=no`:事件升级为当前无已证明可用备份,默认建议 `request_manual_run`,但必须先保证根因不会使补跑无界失败、容量满足停止条件,并单独审批。
|
||||
- 任何手工补跑都只建立过渡恢复点和验证修复,不替代两个自然周期。
|
||||
|
||||
## 安全与脱敏
|
||||
|
||||
- 腾讯云生产和 ARM 地址、账号、绝对现场路径、完整 run id/digest、密钥、known_hosts、webhook、原始日志和业务内容不进入仓库或聊天。
|
||||
- 远端临时探针若获批,固定目标、SHA-256、语法、sudoers 和自清理均需机械校验,并由外层独立报告入口/暂存残留。
|
||||
- timer `active`、partial 存在、单次备份成功或告警 `sent=true` 都是局部证据,不能扩写为链路整体正常。
|
||||
|
||||
## 2026-08-28 只读调查裁决
|
||||
|
||||
- 生产本地存在一个通过完整哈希合同的最新可用批次,风险分类为 `production_single_point`,不是“当前无已证明可用备份”;元数据合格批次 7 个,失败传输后保留本地批次、异机成功后才 prune,过渡动作保持 `wait_natural`。
|
||||
- ARM 原始调查输出曾将两个 partial 判为 `similar`。该输出保留作为原始证据;现场聚合字节为 `2682168` 与 `0`,按修正后的“双阈值同时满足”合同人工重算为 `different`。一个老 partial 已满足完整合同但未 finalize,另一个较新 partial 为零字节,两者不支持“固定相同偏移的确定性传输上限”,且可能来自不同失败类型。
|
||||
- 现有状态未持久化本次失败的 rsync 原始退出码和传输专属时钟,均保持 `NOT_AVAILABLE`;只读证据不足以证明单一根因。若后续自然周期再次失败,按既定边界转为单独审批的一次受控观测性重传,不扩大只读读取范围来补造结论。
|
||||
- 调查时 ARM 已出现约 12 小时 43 分的新鲜完整原子归档,dead-man 最近 service 为 `success/0` 且状态为 healthy,说明异机链已自然恢复一次;该事实可解除恢复演练的事件阻塞,但不满足本 P1 事件的两个自然周期结案条件。
|
||||
- 私有探针输出的 `arm_deadman_alert_state_finding_count=invalid` 是表示缺陷,不是 ARM 状态损坏:`last-deadman.json` summary 才有 `findingCount`,`deadman-alert-state.json` 按稳定 helper 合同只保存 `schemaVersion/status/category`。后续探针不得对 alert state 索取不存在的 count 字段。
|
||||
- ARM health service 同期为 `failed/1`,而生产公网 `/ready` 调查前后均为 PASS、12 位构建前缀一致。该 finding 指向健康监控执行、状态或通知链,不得扩写为 OA 当前不可用;进一步读取或处置需另行授权。
|
||||
@@ -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,90 @@
|
||||
# 生产到 ARM 备份传输中断实施计划
|
||||
|
||||
## Phase 0:规划与授权
|
||||
|
||||
- [x] 创建 P1、`operations` Trellis task,记录来源事件和与恢复演练的阻塞关系。
|
||||
- [x] 固定只读调查、生产/ARM 写操作、手工补跑和测试告警为不同审批边界。
|
||||
- [x] 用户审阅并批准本规划及补充项后运行 `task.py start`;该批准只授权进入本地实施,不自动授权连接远端。
|
||||
|
||||
## Phase 1:本地只读调查准备
|
||||
|
||||
- [x] 从 `scripts/run-automatic-backup.sh`、receiver、dead-man 和 systemd installer 提取真实单位名、状态路径来源、阶段和退出码合同,禁止手写近似常量。
|
||||
- [x] 实现只输出 allowlist 的跟踪只读采集器:生产本地完整恢复点合同、本地成功/失败清理保留语义、partial 匿名字节比例、rsync 原始码、无法取得的传输时钟、两端容量;timer/service 继续复用既有只读 systemd 聚合合同,不在 Python 中另写一套。
|
||||
- [x] 为本地完整/无完整批次、成功清理/失败保留、相近/不同停止偏移、rsync `23/30/12/11/255`、缺失持续时间、权限拒绝、畸形 manifest 和敏感输出补合成回归。
|
||||
- [x] 本地语法、合同、敏感扫描全部通过后,提交远端只读调查摘要与精确权限边界,单独申请执行批准。
|
||||
|
||||
### 本地准备结果(2026-08-28)
|
||||
|
||||
- 跟踪调查器:`scripts/qa/inspect-backup-transfer-incident.py`;仅接受显式绝对路径参数,不回显参数值,未知异常也只输出稳定类别。
|
||||
- 可复跑合同:`scripts/qa/run-backup-transfer-incident-contract.sh`;初始 19 项合成检查通过;现场分类缺陷修复后扩展为 20 项,fixture 与 Python cache 零残留。权限拒绝不会被误写成“无有效备份”,其他阶段的旧日志不会冒充本轮 rsync 诊断;生产多个批次时只哈希最新元数据合格候选,最新哈希失败不回退扫描旧批次;停止偏移必须同时满足 5% 和 64 MiB 双阈值。
|
||||
- 既有 `run-automatic-backup-contract.sh` 74 项通过;生产 wrapper 未改。现状没有持久化传输专属开始/结束时刻,故持续时间必须为 `NOT_AVAILABLE`。
|
||||
- rsync 原始码只在安全摘要明确为 `phase_offhost_transfer_failed` 时从 root 私有 `last-error.log` 固定正则提取,避免把 paired-backup/finalize 的旧诊断错误归因给传输。
|
||||
|
||||
## Phase 2:获批的远端只读调查
|
||||
|
||||
- [x] 在不修改生产/ARM 的前提下采集四类证据;每个临时入口在同轮精确清理并由外层独立复核零残留。
|
||||
- [x] 先判定“生产单点”或“无已证明可用备份”,再交叉判定主要根因、次要候选、清理/累积语义和容量预算;无法取得的字段标为 `NOT_AVAILABLE/NOT_MEASURED`。
|
||||
- [x] 记录过渡恢复点决策;只读证据无法收敛时停止扩读,提交一次受控观测性重传的独立审批方案,不直接执行。
|
||||
- [ ] 更新本 task 证据,给出单一最小修复方案、回滚方案和生产写操作清单;停止并请求用户审阅。
|
||||
|
||||
### 远端只读调查结果(2026-08-28,追加时间线)
|
||||
|
||||
- 公网 `/ready` 调查前后均 PASS,12 位构建前缀均为 `7b86ffb3c9ef`;生产固定入口与 staging 经外层独立枚举均为 0,ARM `arm_independent_residue=0`。调查未改变在线 OA、正式归档、timer 或业务数据。
|
||||
- 生产:`complete_local_recovery_point=yes`、`backup_risk_classification=production_single_point`;完整哈希通过的最新批次 1 个、元数据完整批次 7 个,最新候选年龄 42885 秒、聚合 11400180 bytes;生产文件系统 82% 使用、10 GiB 可用。源码顺序证明传输失败保留完整本地批次、异机成功后 prune、生成临时对象在失败路径清理,故过渡动作是 `wait_natural`。
|
||||
- 生产最近 daily/watchdog service 均为 `success/0`、timer active;历史失败摘要未保留可绑定到本轮的 rsync 原始码或传输专属时钟,因此退出码和时长均为 `NOT_AVAILABLE`,不得用 service exit、mtime 或对话时刻替代。
|
||||
- ARM 原始输出:partial 2 个,聚合 2682168 bytes,样本总字节分别为 2682168 与 0;调查器当时输出 `arm_partial_stop_offset_class=similar`。该原始结果不回写。
|
||||
- 分类缺陷修订:旧实现以 `max(64 MiB, 5%)` 为接近上限,使 2.68 MB 与 0 字节被 64 MiB 阈值错误吞并。按固定阈值必须同时满足的合同,人工重算为 `different`;一个约 32 日龄 partial 满足完整合同但未 finalize,另一个约 36 小时龄 partial 为零字节,不能据此声称两次都停在同一确定性偏移。
|
||||
- ARM 正式原子归档 7 个、无无效归档,最新完整归档年龄 45756 秒;inbox/archive 文件系统 54% 使用、89 GiB 可用;dead-man service `success/0`、timer active、最近状态 `PASS_OFFHOST_DEADMAN_FRESH/healthy`。链路已自然恢复一个新鲜原子批次,可解除恢复演练的事件阻塞,但 P1 仍需第二个连续自然周期及容量验收后才能结案。
|
||||
- `arm_deadman_alert_state_finding_count=invalid` 不表示远端状态损坏。仓库 helper 明确把 alert state 写成仅含 `schemaVersion/status/category` 的去抖状态;`findingCount` 属于 `last-deadman.json` summary。私有探针错误复用了 summary 读取模板,后续证据不得把这个表示缺陷升级为告警链 finding。
|
||||
- 独立 finding:ARM health service 为 `failed/1`,但公网 `/ready` 实测前后一致且 PASS。这是健康监控执行/状态/通知链问题,不是 OA 当前不可用的证据;本次授权不覆盖进一步调查或修改。
|
||||
- 根因裁决:历史单一 rsync 原始码和可靠传输时长不可得,两个 partial 的真实停止位置明显不同,现有证据不能收敛为单一根因。不得把自然恢复一次冒充“已实施修复”;后续若再失败,候选动作是另行批准的一次受控观测性重传。生产 10 GiB 余量仍需独立容量方案。
|
||||
|
||||
### 分类缺陷复盘
|
||||
|
||||
- 根因类别是“隐含假设 + 测试覆盖缺口”:实现用 `max(absolute, relative)` 把两个独立上限误写成二选一宽松上限;旧正例夹具只证明“绝对差异较小”,没有证明相对差异也在 5% 内,因而为错误实现背书。
|
||||
- 修复机制已闭环:实现改为 `min(...)`;合同直接覆盖现场 `2682168/0`、同时满足双阈值的正例、分别越过相对/绝对阈值的负例;稳定 spec 写明公式、错误写法与断言点。仓库搜索确认该分类公式只有这一个实现位置,不存在需要同步的第二套逻辑。
|
||||
- 原始远端 `similar` 保留,人工修订追加为 `different`;以后任何现场分类缺陷都按同样的不可变时间线处理,不回写旧证据。
|
||||
|
||||
## Phase 3:本地修复与变更审批
|
||||
|
||||
- [ ] 仅修改证据支持的脚本/installer/合同;不轮换已通过初始认证的密钥,不放宽 manifest/finalize 门禁。
|
||||
- [ ] 容量方案优先扩容或精确处置已确认的自动备份自有对象,证明不会触碰唯一恢复点、数据库或附件源。
|
||||
- [ ] 运行自动异机备份、receiver、dead-man、故障注入、回滚和敏感输出合同。
|
||||
- [ ] 列出生产/ARM 的每个写目标、旧值指纹、候选 SHA、执行/回滚命令和停止条件,单独申请实际变更批准。
|
||||
|
||||
## Phase 4:获批的实际修复
|
||||
|
||||
- [ ] 执行最小变更和容量处置;不做未批准的手工备份、timer 修改、partial 清理或测试告警。
|
||||
- [ ] 若另获手工补跑批准,只运行一次并记录真实 rsync 码、时长、原子 finalize 和残留;失败不自动重试。
|
||||
- [ ] 验证至少一个新原子批次、manifest/三文件/字节/SHA-256、三组 service exit `0` 和容量预算。
|
||||
|
||||
## Phase 5:两个自然周期观察
|
||||
|
||||
- [ ] 自然周期 1:daily 新原子批次 PASS;watchdog/dead-man service success;记录生产/ARM 容量、ARM partial 数量/聚合字节趋势和适用的告警响应锚点。
|
||||
- [ ] 自然周期 2:重复同一组断言;不以手工补跑或旧批次替代;ARM partial/容量趋势不得继续恶化。
|
||||
- [ ] 两个周期均通过后标记事件 `GO`,否则保留失败并返回 Phase 2/3,不临时放宽阈值。
|
||||
|
||||
## Phase 6:解除阻塞与收口
|
||||
|
||||
- [ ] 在 `08-23-isolated-backup-restore-drill` 追加本任务结果交叉引用并解除事件阻塞;恢复演练仍需新批次身份探针和单独实际恢复审批。
|
||||
- [ ] 运行 task validate、Trellis governance、process baseline、docs registry、敏感扫描和 `git diff --check`。
|
||||
- [ ] 中文提交引用 `08-28-production-arm-backup-transfer-incident`;不自动 push,不归档未满足两个自然周期的任务。
|
||||
|
||||
## 停止条件
|
||||
|
||||
- 只读操作将输出路径、身份、凭据、原始日志/JSON 或业务内容。
|
||||
- 需要新增 sudo、读取超出 allowlist 的 root 状态或改变生产/ARM 状态但尚未获独立批准。
|
||||
- 清理对象、配置目标、旧值或回滚路径不唯一。
|
||||
- 新原子批次不完整、service 非零、容量低于预算或任一自然周期失败。
|
||||
- 告警发送/人工确认锚点缺失时不得补造响应延迟。
|
||||
|
||||
## 验证命令
|
||||
|
||||
- `bash -n` / `python3 -m py_compile` 覆盖新增或修改的运维脚本。
|
||||
- 运行自动备份、receiver、dead-man 和相关敏感输出合同。
|
||||
- `python3 ./.trellis/scripts/task.py validate .trellis/tasks/08-28-production-arm-backup-transfer-incident`
|
||||
- `node scripts/qa/check-trellis-governance.mjs`
|
||||
- `node scripts/qa/check-process-contract.mjs --baseline --json`
|
||||
- `node scripts/qa/audit-docs.mjs --check-registry`
|
||||
- `node scripts/qa/check-sensitive-content.mjs`
|
||||
- `git diff --check`
|
||||
@@ -0,0 +1,113 @@
|
||||
# 生产到 ARM 备份传输中断
|
||||
|
||||
## Goal
|
||||
|
||||
恢复腾讯云生产到 ARM 的自动异机备份链,消除持续扩大的异机恢复点缺口,同时把生产备份落盘容量恢复到可持续范围,并证明修复不是一次偶然成功。
|
||||
|
||||
## Scope
|
||||
|
||||
- 只读定位 partial 停止偏移、rsync 原始退出码、失败持续时间、生产本地批次累积与容量预算。
|
||||
- 基于证据设计并在另行批准后实施最小传输链修复和生产容量处置。
|
||||
- 验证新 ARM 原子归档、daily/watchdog/dead-man 的 timer 与 service,并连续观察两个自然调度周期。
|
||||
- 建立真实告警发送调用时刻、人工确认时刻和响应延迟的脱敏记录合同。
|
||||
- 维护与 `08-23-isolated-backup-restore-drill` 的阻塞交叉引用;不在本任务内执行恢复演练。
|
||||
|
||||
## Background
|
||||
|
||||
- 腾讯云服务器是真实 OA 生产;ARM 是异机备份、dead-man 和隔离恢复节点,不是生产。
|
||||
- 2026-08-27,任务 `08-23-isolated-backup-restore-drill` 的最新批次探针发现 ARM 最新原子归档已超过 24 小时目标;随后获批的只读调查确认生产 daily/watchdog timer 正常触发,但对应 service 最近均以 `exit-code/1` 失败。
|
||||
- ARM inbox 有两个未完成 `.partial`,其中最新一个与最近自然备份时点对应;两者均未满足三文件、manifest 身份和产物字节数的完整传输合同,正式归档事务临时对象为零。因此当前 finding 为 `offhost_transfer_incomplete`,不是调度漏跑或完整传输后的 finalize 失败。
|
||||
- partial 已证明 rsync 通过初始 SSH 认证、host key 校验和 receiver 授权并开始写入。密钥失效、`authorized_keys` 初始拒绝和 host key 初始拒绝不是首要假设;仍需调查传输中段的源端读取、网络/超时、目标写入和会话/传输限制。
|
||||
- ARM dead-man 以显式 `93600` 秒阈值正确判定 `archive_stale`,且 journal 聚合存在一次 `sent=true`。检测与发送调用没有被去抖静默抑制,但该告警没有形成及时人工响应;本次首次知晓时刻未采集,历史响应延迟不可补造。
|
||||
- 生产相关文件系统只读观测约为 82% 使用、10 GiB 可用。现有证据不能把本次传输中断直接归因于空间不足,但该余量不足以作为持续生成完整 dump、storage 归档和失败重试的安全基线,必须与传输故障并行处置。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 分阶段授权与数据保护
|
||||
|
||||
- 任务创建和规划不授权连接生产或 ARM,也不授权任何远端写入。
|
||||
- 第一阶段只读调查必须另行批准,只允许读取聚合字节数、rsync 原始退出码、固定阶段时刻/耗时、systemd 状态、源端本地批次聚合和文件系统容量;不得输出远端地址、完整路径、run id、文件名、原始 stderr/journal/JSON、凭据或业务内容。
|
||||
- 清理空间、删除或移动本地/ARM partial、调整 SSH/rsync/systemd 参数、修改 timer、重启服务、手工触发备份、重跑传输或发送测试告警均属于生产/ARM 写操作,必须在根因和变更方案审阅后单独明确批准。
|
||||
- 不修改或删除已有 ARM 正式原子归档;诊断不得把真实备份下载到开发机。
|
||||
|
||||
### R2. 只读根因定位
|
||||
|
||||
- 第一项先判断生产本地是否至少存在一个完整可用批次,并对最新本地候选执行与异机归档相同的 manifest schema/status、精确三文件集合、run id 配对、字节数和 SHA-256 校验。输出只含 `complete_local_recovery_point=yes|no`、年龄、数量和聚合大小。若为 `yes`,当前风险是异机副本缺失/生产单点;若为 `no`,当前不存在任何已证明可用的备份,事件风险和过渡恢复点优先级立即升级。
|
||||
- 分别核对成功运行和失败运行对生产本地批次/临时对象的保留与清理行为:成功后是否按保留策略清理,失败后是否保留完整本地批次、是否残留生成期临时对象,以及连续失败是否逐轮累积。不得仅统计“是否累积”而遗漏清理语义。
|
||||
- 比较两个 partial 的聚合总字节数、各预期产物已写入字节数或安全比例,不输出文件名/路径。相同或接近停止偏移作为确定性限制信号,明显不同偏移作为网络/超时等间歇中断信号;阈值和计算口径须在取数前固定。
|
||||
- 从生产备份 wrapper 的私有状态/诊断中提取真实 rsync 退出码和稳定类别,不以 systemd/service 的通用 exit `1` 替代。至少区分 rsync `23`、`30`、`12`、`11` 与 SSH 层 `255`;不得输出原始错误文本。
|
||||
- 记录每次失败的传输阶段开始、失败时刻和持续秒数,判断是否收敛到相近时长;无法从现有状态可靠取得时明确标为未测,不从 mtime 或聊天时间猜测。
|
||||
- 只读统计生产当前可用空间、最近完整本地批次聚合大小、当前本地完整/不完整/临时批次数量及成功/失败路径的清理与累积行为,形成容量预算。
|
||||
- 将 partial 偏移、rsync 原始码、失败时长和两端容量交叉判定,输出一个主要根因类别及仍未排除的候选,不为尽快修复而伪造单一确定根因。
|
||||
- 若现有只读证据不能收敛,尤其是故障疑似位于不可控链路侧,不扩大远端读取范围或反复追加低信号探针;转为设计“一次受控观测性重传”,冻结输入、实时采集允许字段并有界停止,作为独立审批项。该路径不在只读调查授权内。
|
||||
|
||||
### R3. 传输链修复
|
||||
|
||||
- 只修复 R2 证据直接支持的最小故障面;不得顺手轮换已证明可以完成初始认证的密钥、重建 receiver、放宽 manifest/finalize 安全门禁或修改恢复 runner。
|
||||
- 若根因为超时/会话限制,变更须有明确的客户端、服务端或中间链路归属、取值依据、回滚值和有界失败行为;禁止无限超时或无界重试。
|
||||
- 若根因为源端读取、目标写入、网络或 rsync 行为,优先修复可机械验证的具体错误,并保留原子 finalize、partial 隔离和失败关闭。
|
||||
- 生产和 ARM 的实际变更必须先形成脱敏变更清单、备份/回滚点与验证命令,再单独申请执行批准。
|
||||
|
||||
### R4. 生产容量处置
|
||||
|
||||
- 基于最近成功批次和当前生成流程计算至少容纳一个完整新批次的实际峰值预算,并增加保守安全余量;容量阈值必须在生产写操作前写入设计和证据。
|
||||
- 优先识别可安全处置的自动备份自有产物、失败临时对象或保留策略问题;任何删除/移动必须精确匹配已验证身份,禁止宽泛清理、跨任务清理或触碰生产数据库/附件源数据。
|
||||
- 容量处置不得以降低备份保留、删除唯一恢复点或跳过本地完整批次为代价。若空间扩容比删除更安全,应选择扩容方案并单独审批。
|
||||
- 修复后生产必须具备“一个完整新批次实际峰值 + 安全余量”的可验证空间,且两个自然周期内空间趋势不继续逼近失败线。
|
||||
|
||||
### R5. 恢复验证与持续观察
|
||||
|
||||
- 修复后必须形成新的 ARM 原子归档批次,而不是仅出现 partial;该批次须通过 manifest schema/status、精确三文件集合、run id/目录配对、字节数和 SHA-256 校验。
|
||||
- daily、watchdog、dead-man 不得仅以 timer `active` 作为通过。对应 timer 必须正常触发,最近 service 必须 `Result=success`、退出码 `0`,状态摘要须与新鲜归档一致。
|
||||
- 必须连续观察至少两个自然 daily 调度周期,每个周期都形成新的完整原子归档并通过相同合同;手工补跑成功不能替代两个自然周期。
|
||||
- 两个周期都必须记录 ARM inbox partial 数量/聚合字节变化和 ARM 文件系统可用量/使用率趋势;不得只观察生产容量,避免传输失败对象在 ARM 侧继续累积形成新的容量故障。
|
||||
- 观察期间不得为通过验收而删除失败证据、改选旧批次或临时放宽新鲜度/完整性阈值。
|
||||
|
||||
### R5a. 过渡恢复点决策
|
||||
|
||||
- R2 得出本地备份可用性后,必须单独记录 `transition_recovery_point_action=wait_natural|request_manual_run` 及理由。
|
||||
- 若生产本地已有完整可用批次且下一自然周期时间/容量风险可接受,优先等待自然调度;若生产本地也没有完整可用批次,手工补跑建立过渡恢复点的优先级显著上升,但仍须先有根因/容量停止条件并取得单独批准。
|
||||
- 手工补跑即使形成过渡恢复点,也不计入 AC5 的两个自然周期;若没有获批,不得为缩短风险窗口自行执行。
|
||||
|
||||
### R6. 告警送达与响应闭环
|
||||
|
||||
- 保留本次“检测与发送调用正常、响应未及时形成”的双重结论,不把 `sent=true` 等同于人员已阅读。
|
||||
- 从本任务开始,每次真实备份/dead-man 告警须分别记录脱敏的发送调用时刻和首位责任人确认时刻,并计算响应延迟;不得记录 webhook、群标识、人员账号或消息原文。
|
||||
- 若后续告警仍只有 `sent=true` 而无人确认,调查群送达、可见性、责任人和升级流程;发送测试告警须单独批准。
|
||||
|
||||
### R7. 与恢复演练解耦
|
||||
|
||||
- 本任务优先级为 P1、scope 为 `operations`,高于 P2 的 `08-23-isolated-backup-restore-drill`。
|
||||
- 演练任务保持 `in_progress`,明确被本任务阻塞;在新鲜原子批次形成并通过 24 小时身份门禁前不得继续实际恢复。
|
||||
- 既有恢复候选 SHA 保持有效,无需因本事件重新渲染;本任务不修改恢复 runner 或冒充恢复可用性结论。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:只读调查首先明确生产本地是否存在通过完整合同校验的可用批次,并区分“异机副本缺失/生产单点”与“当前无已证明可用备份”;同时取得成功/失败运行的清理保留语义、两个 partial 的聚合停止偏移、真实 rsync 退出码、可靠失败持续时间(或明确未测原因)、生产空间与累积状态,形成证据支持的根因分类和过渡恢复点建议。
|
||||
- [ ] AC2:经单独批准的最小传输修复与容量处置完成,变更、回滚点和最终容量预算有脱敏证据;未修改正式归档、恢复门禁或业务数据。
|
||||
- [ ] AC3:ARM 形成至少一个新的完整原子归档批次,并通过 manifest、精确文件集、run id 配对、字节数和 SHA-256 校验;partial 不得冒充完成。
|
||||
- [ ] AC4:生产 daily/watchdog 与 ARM dead-man 的 timer 正常触发,最近 service 均 `Result=success`、exit `0`,新鲜度状态与最新原子归档一致。
|
||||
- [ ] AC5:连续两个自然 daily 调度周期均产生新的完整原子归档并通过 AC3/AC4;手工补跑不计入这两个周期;两个周期的 ARM partial 数量/聚合字节和 ARM 容量趋势均已记录且未继续恶化。
|
||||
- [ ] AC6:生产容量达到经审阅的“一个完整新批次实际峰值 + 安全余量”预算,两个自然周期内无继续逼近失败线的趋势。
|
||||
- [ ] AC7:告警响应计时合同已启用;观察窗口内若出现真实告警/恢复通知,则同时记录发送调用时刻、人工确认时刻和响应延迟;若没有自然通知,明确标为未触发,不发送未批准的测试消息,也不把缺样本写成响应闭环已验证。
|
||||
- [ ] AC8:演练任务保留阻塞交叉引用;task validate、Trellis governance、流程合同、文档审计、敏感扫描和 `git diff --check` 通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 本轮规划不授权连接生产/ARM、不授权只读调查执行,也不授权任何修复、清理、重跑或告警发送。
|
||||
- 不恢复数据库或附件,不启动隔离 OA,不修改恢复候选、migration、业务代码、生产数据库或业务附件。
|
||||
- 不轮换备份 SSH 密钥、不重建 receiver,除非后续只读证据推翻“初始认证已成功”的当前事实并重新审阅设计。
|
||||
- 不删除 ARM 正式归档或历史 partial;历史 partial 的清理须在身份与保留价值审阅后单独批准。
|
||||
- 不把一次手工成功、timer active、service 通用 exit 或 `sent=true` 单独作为结案依据。
|
||||
|
||||
## Blocking Open Questions
|
||||
|
||||
- 无。第一阶段只读调查、任何本地实现以及生产/ARM 写操作仍分别需要后续明确批准。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 本地使用合成 fixture 验证 partial 聚合比较、rsync 退出码映射、失败时长聚类、容量预算和敏感输出 allowlist。
|
||||
- 获批后对生产/ARM 只读采集 R2 字段,保持原始日志、路径、身份和业务数据不出远端私有边界。
|
||||
- 修复候选先通过 Bash/Python 语法、既有自动异机备份/receiver/dead-man 合同和回滚演练;实际变更另行批准。
|
||||
- 实际修复后校验新原子批次与三组 timer/service,并连续观察两个自然调度周期。
|
||||
- 运行 task validate、Trellis governance、process baseline、docs registry、敏感扫描和 `git diff --check`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "production-arm-backup-transfer-incident",
|
||||
"name": "production-arm-backup-transfer-incident",
|
||||
"title": "生产到 ARM 备份传输中断",
|
||||
"description": "调查并修复生产到 ARM 的自动异机备份传输中断,同时处置生产容量风险并验证连续两个自然调度周期。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "operations",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "来源证据见 08-23-isolated-backup-restore-drill 的 2026-08-27 自动备份链只读调查。创建与规划不授权连接生产/ARM;只读深查、生产/ARM 写操作、手工补跑和测试告警分别审批。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 设计:报销详情修复部署与公网验证
|
||||
|
||||
## 当前依赖
|
||||
|
||||
部署候选必须包含 Task A 和 `08-28-reimbursement-nonparticipant-readonly-projection`。业务提交为 `e8d6766`;本次 PRD 修订提交完成后,以最终干净 `HEAD` 冻结候选,在外部审批记录中写死 SHA,此后不得再产生任何仓库变化。用户必须先单独批准推送;upstream 与严格 provenance 验证通过后,再单独批准连接生产和部署。
|
||||
|
||||
## 发布流
|
||||
|
||||
```text
|
||||
提交本次 PRD 修订并确认工作区干净
|
||||
-> 冻结当前 HEAD,在外部审批记录写死 SHA
|
||||
-> 用户单独批准 push
|
||||
-> push + upstream 祖先关系 + 严格 provenance
|
||||
-> 用户单独批准生产部署
|
||||
-> 生产身份与 existing 部署合同只读 preflight
|
||||
-> 期望 migration 清单核对
|
||||
-> deploy-existing.sh(SYNC_ENV=0)
|
||||
-> init + migrate deploy + post migration status
|
||||
-> /ready + runtime/external smoke + bundle marker
|
||||
-> 备份合同 refresh
|
||||
-> 角色化公网检查和材料预览
|
||||
-> 归档部署子任务与 Task A
|
||||
```
|
||||
|
||||
- 正式入口使用仓库现有 wrapper,不手工 rsync、不 source 或覆盖远端 `.env`。
|
||||
- 本轮代码不包含新 schema,但 init 和 pre/post migration status 仍是必需证据。
|
||||
- `pdf-v2` 缓存仅在进程内,应用重启后自然失效,无缓存迁移或清理动作。
|
||||
- 真实材料检查只记录页数、页序、比例和成功/失败,不保存截图、原件或票面字段。
|
||||
|
||||
## 固定回滚
|
||||
|
||||
- 切换前失败:停止,不进入回滚,因为生产应用尚未改变。
|
||||
- 切换后任一必需门禁失败:停止新版本验证,使用同一 existing 部署入口发布固定提交 `f178aa0`,保持 `SYNC_ENV=0`,不回退或 reset migration。
|
||||
- 回滚后必须确认 `/ready` 和 bundle marker 为 `f178aa0` 的短 SHA,并复核 migration status、runtime/external smoke、DB/附件挂载与公网入口。
|
||||
- 回滚来源同样必须干净、已发布且远端可验证;不得以 unpublished override 绕过正式 wrapper。
|
||||
|
||||
## 授权边界
|
||||
|
||||
任务规划批准不等于推送批准,推送批准也不等于生产部署批准。候选冻结后先取得独立 push 授权并完成 upstream/provenance 验证,再取得独立部署授权;连接生产、应用切换和合成 owner 撤回写入均不得从前一项授权推定。真实报销永不作为撤回测试对象。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 实施清单:报销详情修复部署与公网验证
|
||||
|
||||
## 1. 依赖与候选
|
||||
|
||||
- [x] P0 非参与人只读投影修复已实施、只读复核 PASS 并提交为 `e8d6766`。
|
||||
- [ ] 本次 PRD 修订提交完成后确认工作区干净,以最终 `HEAD` 冻结候选,并在外部审批记录写死 SHA;冻结后不得再提交或修改工作区。
|
||||
- [ ] 用户已审查最终候选、回滚和公网验证清单,并单独明确批准推送冻结候选。
|
||||
- [ ] 推送后确认 upstream 包含候选,严格 provenance 通过且未使用 unpublished override。
|
||||
- [ ] 来源验证通过后,用户另行明确批准连接生产和正式部署。
|
||||
|
||||
## 2. 部署前门禁
|
||||
|
||||
- [ ] 工作区干净且 HEAD 等于外部审批记录中的候选;候选与 `f178aa0` 均已发布且来源可远端验证。
|
||||
- [ ] 生产 identity、部署合同、DB/附件 existing 路径、运行容器和回滚窗口只读 preflight 全部通过。
|
||||
- [ ] 明确期望 pending migrations;本轮若仍无新增 migration,期望值写为空但不跳过 status。
|
||||
|
||||
## 3. 正式发布
|
||||
|
||||
- [ ] 使用 `deploy-existing.sh`、`DEPLOY_DATA_MODE=existing`、`SYNC_ENV=0` 执行发布。
|
||||
- [ ] init、migrate deploy、post migration status、runtime/external smoke、`/ready`、bundle marker 和备份合同 refresh 全部通过。
|
||||
|
||||
## 4. 公网验证
|
||||
|
||||
- [ ] `/ready` 与前端 marker 等于候选短 SHA,迁移状态正常。
|
||||
- [ ] 财务/管理员非参与人无报销人维护入口;owner 视角维护入口正确。
|
||||
- [ ] 仅在最终授权包含合成测试写入且存在专用合成单据时验证 owner 撤回成功;否则停止,不操作真实单据。
|
||||
- [ ] 材料合并预览可打开,页序和比例正常;不保存真实票据截图或原件。
|
||||
|
||||
## 5. 失败与回滚
|
||||
|
||||
- [ ] 切换前失败直接停止;切换后任一必需门禁失败,使用同一入口回滚固定提交 `f178aa0`。
|
||||
- [ ] 回滚不 reset migration、不覆盖环境或数据目录;回滚后复核 build、status、smoke、挂载和公网入口。
|
||||
|
||||
## 6. 收口
|
||||
|
||||
- [ ] 公网全部通过后才报告用户可测试,回填脱敏证据和实际部署 SHA。
|
||||
- [ ] 先归档部署子任务,再按真实完成证据归档 Task A;任何未完成门禁保持未勾选。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 报销详情修复部署与公网验证
|
||||
|
||||
## Goal
|
||||
|
||||
在 Task A 与非参与人只读投影修复都完成后,冻结新的已发布候选,执行受控生产部署、显式迁移状态校验、角色化公网检查和固定回滚验证。
|
||||
|
||||
## Current Planning Gate
|
||||
|
||||
- 原候选 `174cb7a` 只包含撤回投影与 EXIF 尺寸修复,未覆盖已确认的非参与人草稿/驳回维护动作和服务端解绑资格问题,因此不再作为最终部署基线。
|
||||
- `08-28-reimbursement-nonparticipant-readonly-projection` 已实施、只读复核并形成业务提交 `e8d6766`;部署工具只能以干净工作区的当前 `HEAD` 作为来源,因此旧口径 `8c28e15` 作候选失效。
|
||||
- 本次 PRD 修订提交完成后,以当时最终 `HEAD` 冻结候选,并在外部审批记录中写死完整/短 SHA;冻结后到部署收口前不得再产生任何提交(包括文档提交)、untracked 文件或工作区修改。若 HEAD 或工作区变化,原审批失效,必须重新冻结和审查。
|
||||
- Requirements、Acceptance Criteria、`design.md` 与 `implement.md` 已写明回滚到 `f178aa0` 的触发条件/动作、迁移 pre/post status、公网角色和材料预览检查;本任务继续保持 `planning`,等待用户先审查候选并单独批准推送,再单独批准生产部署。
|
||||
- 当前未经批准不得推送、部署、写生产或归档 Task A。
|
||||
|
||||
## Scope
|
||||
|
||||
- 在 P0 只读投影修复完成并提交后,以最终干净 `HEAD` 冻结包含两轮报销修复的候选 SHA;候选冻结后禁止任何仓库变化。
|
||||
- 推送是独立、显式授权的发布门:取得单独推送批准后,只推送已冻结候选所在分支,再确认 upstream 包含该 SHA 且 provenance 未使用 unpublished override。推送成功不等于批准部署。
|
||||
- 使用现有 `deploy/trialrun/deploy-existing.sh` 完成生产只读 preflight、existing 模式部署、init、迁移状态复核、运行时 smoke、构建标识和备份合同刷新。
|
||||
- 部署后核验财务/管理员非参与人只读视图、owner 维护/撤回合同和材料合并预览;不把真实票据内容写入证据。
|
||||
- 固定回滚基线为 `f178aa0`;部署切换后的任一必需门禁失败时执行同一正式入口回滚应用代码,不回退数据库 migration。
|
||||
|
||||
## Requirements
|
||||
|
||||
- 候选冻结时必须确认工作区完全干净,以当前分支 `HEAD` 作为唯一候选并记录 SHA;之后任何提交、tracked/untracked 变化或 HEAD 漂移都使候选失效。
|
||||
- 推送必须另行获得明确批准。推送后必须确认当前分支有 upstream、候选是 upstream 的祖先,并以默认严格模式通过 `check-source-provenance.mjs`;不得使用 `allowUnpublished` 或接受 `overrideUsed: true`。
|
||||
- 只有候选已推送且来源校验通过后,才可请求另一项独立的生产部署批准。部署批准不得从 PRD 审批或推送批准中推定。
|
||||
- 部署前必须显式复核候选提交范围、生产身份、existing 数据目录、待迁移清单和回滚窗口;来源未发布、工作区不干净、HEAD 不等于已批准候选或身份不唯一时停止。
|
||||
- 即使候选没有新增 migration,init 后仍必须显式运行并记录 Prisma migration status;不得因“无 schema 变更”跳过。
|
||||
- `SYNC_ENV=0`,不覆盖生产环境文件;不 reset 数据库、不覆盖 DB/附件目录、不清理 `pdf-v2` 进程内缓存,应用重启自然失效。
|
||||
- 公网 `/ready` 和前端 bundle marker 必须等于最终候选短 SHA;迁移、runtime/external smoke、备份合同刷新和角色化检查全部为必需门禁。
|
||||
- 非参与人只读检查不得写业务数据。owner 撤回成功只能使用专用合成单据并在最终部署授权中单列生产测试写入;没有合格合成单据时该项保持阻断,不得操作真实报销。
|
||||
- 切换前门禁失败只中止部署;切换后必需门禁失败则回滚到 `f178aa0`,再复核该基线 `/ready`、bundle、migration status、runtime smoke 和数据挂载连续性。
|
||||
- 公网检查全部通过前不告知用户可以测试;通过后先归档部署子任务,再按真实完成证据归档 Task A。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 非参与人只读投影修复已实施、复核并提交为 `e8d6766`。
|
||||
- [ ] 本次 PRD 修订提交完成后的最终干净 `HEAD` 已冻结,具体 SHA 已在外部审批记录中写死,且冻结后无任何仓库变化。
|
||||
- [ ] 完整部署 PRD、设计、实施和回滚清单经用户审查;用户已单独批准推送冻结候选。
|
||||
- [ ] 推送完成,upstream 包含冻结候选,严格 provenance 通过且 `overrideUsed` 不为 true。
|
||||
- [ ] 用户在来源校验通过后另行明确批准生产部署。
|
||||
- [ ] 最终候选的 `/ready`、bundle marker、迁移状态、runtime/external smoke 和备份合同刷新全部通过。
|
||||
- [ ] 财务/管理员非参与人无报销人维护动作,owner 既有维护合同正常,材料合并预览可打开且页序和比例正常。
|
||||
- [ ] 任一必需门禁失败时已按固定动作回滚到 `f178aa0` 并完成回滚后复核,未临场选择其他基线。
|
||||
- [ ] 公网检查通过后才报告可测试并归档相关任务。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 在部署任务内继续修改报销业务代码、权限、状态机、schema、migration 或生产配置。
|
||||
- 处理 `BX-20260828-0017` 票面差额、退回、付款、重传或任何其他真实单据写入。
|
||||
- Task B 产品改造、r102 测试债、企业微信写入、真实付款、数据库 reset 或附件清理。
|
||||
- 为 `pdf-v2` 执行缓存迁移或清理;缓存仅存在进程内。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 冻结与推送:干净工作区、当前分支 `HEAD`、外部审批记录中的固定 SHA、独立 push 批准、upstream 祖先关系和严格 provenance(禁止 unpublished override)。
|
||||
- 部署前:独立部署批准、候选范围/上游来源、`check-existing-deployment.sh`、production identity/preflight、期望待迁移清单和回滚窗口。
|
||||
- 部署入口:`deploy-existing.sh` 的 existing 模式、`SYNC_ENV=0`、init、pre/post Prisma status、runtime/external smoke、`/ready`、bundle marker 和合同 refresh。
|
||||
- 公网角色:财务/管理员非参与人只读、owner 维护/撤回、材料合并预览与原件降级;只保留脱敏结论。
|
||||
- 回滚演练:从受控干净来源发布 `f178aa0`,复核构建标识、迁移状态、运行 smoke、挂载和公网入口。
|
||||
- 治理:task validate、Trellis governance、敏感检查和 `git diff --check`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-detail-material-preview-deploy",
|
||||
"name": "reimbursement-detail-material-preview-deploy",
|
||||
"title": "报销详情修复部署与公网验证",
|
||||
"description": "在报销详情两轮权限修复完成后,以新的已发布候选执行受控部署、迁移状态校验、固定回滚和公网角色验证。",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": "production deployment preflight; migration status; public role and material-preview verification; fixed rollback",
|
||||
"package": null,
|
||||
"priority": "P0",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-28-reimbursement-detail-material-preview-fixes",
|
||||
"relatedFiles": [],
|
||||
"notes": "旧候选 8c28e15 因后续文档提交导致 HEAD 漂移而失效。以本次 PRD 修订提交完成后的最终干净 HEAD 冻结新候选,并在外部审批记录中写死 SHA;冻结后禁止任何仓库变化。push 与生产部署是两个独立授权门,当前均未批准。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# 设计:报销详情权限与材料预览缺陷修复
|
||||
|
||||
## 边界与原则
|
||||
|
||||
本任务是两个小型缺陷修复加一项生产只读诊断,不改变业务权限、审批状态或数据模型。前端修复负责把服务端已有的对象资格正确投影为可见动作;后端修复只校正图片规范化结果的尺寸事实。真实材料诊断与代码修复分离,诊断结果不能成为扩大实现范围的默许授权。
|
||||
|
||||
## R1 动作资格数据流
|
||||
|
||||
```text
|
||||
报销详情 DTO ownerId / submitterId + 当前用户 me.id
|
||||
-> isReimbursementParticipant
|
||||
-> ReimbursementActions.showWithdrawAction
|
||||
-> reviewing 状态下按“参与人 AND 状态”显示撤回
|
||||
```
|
||||
|
||||
- 复用 `ReimbursementDetailPage` 已有 `isReimbursementParticipant`,不新增页面级权限枚举或 API 字段。
|
||||
- `ReimbursementActions.showWithdrawAction` 改为必填 prop 并移除默认值。全仓库当前只有 PC 报销详情一个调用点,由 TypeScript 强制调用方显式传入参与人资格,后续新增调用也默认 fail-closed。
|
||||
- owner/submitter 的既有交互、默认撤回说明、mutation 和反馈不变。
|
||||
- Playwright 使用两类合成身份验证:现有 owner 夹具继续看到撤回;复用 `r102-reimbursement-main-flow.spec.ts` 中 `adminMe + ownerId=employee-other-r102 + status=reviewing + scope=all` 的非参与管理员夹具断言撤回不可见。管理员覆盖当前 fail-open 权限面的最宽视角,服务端既有 403 测试继续作为真正安全边界。
|
||||
|
||||
## R2 图片规范化数据流
|
||||
|
||||
```text
|
||||
源 JPG/PNG/WebP
|
||||
-> sharp(source).rotate() 应用 EXIF
|
||||
-> flatten + JPEG 编码
|
||||
-> toBuffer({ resolveWithObject: true })
|
||||
-> info.width / info.height(实际输出尺寸)
|
||||
-> fitPage
|
||||
-> PDF page + drawImage
|
||||
```
|
||||
|
||||
- 不再在输出前使用 Sharp 输入 metadata 作为页面尺寸。
|
||||
- `resolveWithObject` 同时返回规范化 buffer 和实际输出 info,避免为了取尺寸再解码一次。
|
||||
- 输出宽高仍执行存在性、整数性和 `maxImagePixels` 检查;输入解码继续由 Sharp 的 `limitInputPixels` 限制。若 Sharp 输出缺少合法尺寸,沿用 `material_preview_image_invalid`。
|
||||
- 合并器版本从 `pdf-v1` 升级为下一稳定版本,使集合哈希和 cache key 明确区分旧算法。缓存仍只在进程内、按 grant 生命周期管理,不迁移数据。
|
||||
- 测试用 Sharp 生成无敏感内容的方向 6/8 JPEG。除页数和确定性外,读取合并 PDF page size,断言其纵横方向等于自动旋转后的实际输出;保留普通 PNG/JPG/WebP 基线。
|
||||
|
||||
## R3 真实单据只读诊断
|
||||
|
||||
```text
|
||||
受控生产只读定位业务单号
|
||||
-> 冻结有序材料最小元数据
|
||||
-> 必要时流式读取材料到安全临时目录/内存
|
||||
-> 读取 EXIF 和规范化输出尺寸
|
||||
-> 用当前合并器生成临时派生 PDF
|
||||
-> 核对页序、页数、目标页尺寸与是否存在图像对象
|
||||
-> 删除临时材料
|
||||
-> 仅保留脱敏结论
|
||||
```
|
||||
|
||||
- 现有顺序与单据事实已确认两张单页 PDF 发票在前、两张 JPG 附件在后,因此用户口中的“第一张图”固定对应合并 PDF 第 3 页;诊断直接从第 3 页开始,同时核对总页数,避免把 PDF 第 1 页误作目标图片。
|
||||
- 只在元数据不足以定因时读取最小原件字节;不进行 OCR、不展示内容、不输出完整文件名和存储路径。
|
||||
- 诊断记录只允许:材料类别、相对顺序、MIME、脱敏尺寸/方向、合并页数/页尺寸、错误类别和修复覆盖结论。
|
||||
- 若现象可由旋转后尺寸不一致完整解释,R2 为修复;若 PDF 页本身缺少可复制内容或存在表单/可选内容依赖,停止在证据结论,另行规划兼容方案。
|
||||
|
||||
## 兼容性与回滚
|
||||
|
||||
- 无 API、schema、迁移、权限或状态机变更。
|
||||
- 前端回滚:同时恢复 prop 默认值和调用点合同才会恢复旧投影;正常修复不允许只移除调用点参数,因为必填类型应在编译期阻断该回退错误。服务端安全边界始终不变。
|
||||
- 后端回滚:恢复旧图片尺寸读取并回退 merger version。已签发短时 bundle 本就随进程/TTL 失效,无持久化产物迁移。
|
||||
- 未部署前仅能报告本地验证与只读诊断结论;公网修复需要单独批准部署并完成构建号和关键路径检查。
|
||||
|
||||
## 关键权衡
|
||||
|
||||
- 选择复用现有参与人布尔值,而不是增加新的通用授权抽象:当前只有一个明确调用缺口,扩大抽象不会增加安全性。
|
||||
- 选择读取 Sharp 输出 info,而不是交换 metadata 宽高:EXIF 不只有 6/8,输出事实比枚举推断更可靠。
|
||||
- 不在本任务替换 `pdf-lib`:首图的 PDF 特殊内容兼容仍未被证据确认,且更换渲染链会改变确定性、资源上限和签名事实边界。
|
||||
@@ -0,0 +1,59 @@
|
||||
# 实施清单:报销详情权限与材料预览缺陷修复
|
||||
|
||||
## 1. 实施前只读诊断
|
||||
|
||||
- [x] 确认当前任务已获最终方案实施批准,并加载 `trellis-before-dev`。
|
||||
- [x] 使用受控只读路径冻结 `BX-20260828-0017` 的材料顺序与最小元数据,不打印敏感字段;按“两张单页 PDF 发票在前、两张 JPG 附件在后”的既有事实,将第一张图片锁定为合并 PDF 第 3 页。
|
||||
- [x] 在运行中 server 容器内以只读事务和内存缓冲复现当前合并结果,从第 3 页核对目标图片的 EXIF、规范化输出尺寸、PDF 页序和页尺寸;未写生产、未落临时原件。
|
||||
- [x] 诊断确认两张 JPG 均无 EXIF 方向标记,第 3/4 页比例正确且存在图像内容;两张源 PDF 也均有普通内容流,不存在 AcroForm/可选内容层依赖。当前数据无法复现“第 3 页无内容”,因此 EXIF 修复不宣称解决 `0017` 现象,本轮不扩展 PDF 引擎。
|
||||
|
||||
## 2. PC 撤回动作资格
|
||||
|
||||
- [x] 将 `ReimbursementActions.showWithdrawAction` 改为无默认值的必填 prop,并在 PC 报销详情调用点显式传入 `showWithdrawAction={isReimbursementParticipant}`。
|
||||
- [x] 保留 owner/submitter reviewing 状态的撤回能力、mutation 和反馈,不改组件外的权限或状态判断。
|
||||
- [x] 在 `r102-reimbursement-main-flow.spec.ts` 复用现有 admin 非参与人全范围夹具,断言撤回按钮和意见框均不存在;保留现有 owner 可见回归和 PWA Service Worker 隔离合同。
|
||||
|
||||
## 3. EXIF 合并修复
|
||||
|
||||
- [x] 调整图片规范化函数,从实际 JPEG 输出 info 读取宽高并校验合法性。
|
||||
- [x] 更新材料合并器版本,确保缓存身份与算法版本一致。
|
||||
- [x] 增加 orientation 6/8 合成图片回归,断言合并页方向/比例;保留普通图片、页数、确定性、限额和失败路径测试。
|
||||
|
||||
## 脱敏只读诊断结论
|
||||
|
||||
- 材料顺序为 2 张单页 PDF 发票、2 张 JPEG 附件,共 4 页;用户所称“第一张图”为第 3 页。
|
||||
- 两张 JPEG 的输入与规范化尺寸比例一致,均无 EXIF orientation;当前合并 PDF 第 3、4 页均存在内容,页面比例与图片一致。
|
||||
- 两张源 PDF 均存在普通页面内容流,未发现 AcroForm 或可选内容层;当前合并结果也保留对应页面内容。
|
||||
- 因而:代码中的 EXIF 尺寸读取缺陷成立并应修复,但不是 `BX-20260828-0017` 当前材料的直接原因;该单的“第一张图未显示”在本次只读内存复现中未重现,可能与当时的短时 bundle/阅读器状态有关,不能无证据归因。
|
||||
- 下一步:服务端材料链路已排除。若现象复现,需向用户确认具体页码、浏览器或企业微信 WebView 版本,以及是否稳定复现,再决定是否创建新的客户端诊断任务;本轮不在服务端继续扩面。
|
||||
|
||||
## 4. 验证与只读复核
|
||||
|
||||
- [x] 运行聚焦后端 Vitest、修改文件 `node --check` 和 Prisma validate。
|
||||
- [x] 运行前端 typecheck/build 与聚焦 Playwright。
|
||||
- [x] 运行流程、文档、Trellis 治理及 `git diff --check`。
|
||||
- [x] 只读检查 diff,确认未改变服务端撤回授权、权限矩阵、审批状态机、材料顺序、授权 grant、审计或原件降级合同。
|
||||
- [x] 按 `trellis-update-spec` 将“旋转后以输出尺寸建页”和“可见算法变化升级 merger version”沉淀到报销材料规约。
|
||||
|
||||
## 5. 交付与回滚点
|
||||
|
||||
- [x] 汇总代码变化、合成测试结果和 `BX-20260828-0017` 脱敏诊断结论,不附真实原件或敏感标识。
|
||||
- [x] 将 `BX-20260828-0017` 的真实处置作为单独业务决定提醒:按现行 PDF-only 规则由业务选择退回重传或财务留痕例外接受,本任务不代为处理。
|
||||
- [x] 提交信息使用中文并引用 `08-28-reimbursement-detail-material-preview-fixes`。
|
||||
- [x] 未经单独批准不部署;如后续批准部署,必须完成公网构建号和关键路径只读检查后才能通知用户测试。
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `client/src/pages/ReimbursementDetailPage.tsx`:PC 撤回动作资格投影。
|
||||
- 覆盖 PC 报销详情的现有或聚焦 Playwright spec:owner/财务双视角回归。
|
||||
- `server/src/services/reimbursementMaterialPdf.js`:Sharp 输出尺寸读取。
|
||||
- `server/src/services/reimbursementMaterialPreview.js`:合并器版本。
|
||||
- `server/tests/unit/services/reimbursementMaterialPreview.test.js`:EXIF、页面尺寸和确定性回归。
|
||||
|
||||
## 验证记录
|
||||
|
||||
- 聚焦后端:3 个测试文件、23 项测试全部通过;含材料合并、附件路由和非参与人撤回 403 回归。
|
||||
- 前端:typecheck 与 production build 通过;admin 非参与人撤回不可见、owner 撤回可用两条聚焦 Playwright 均通过。
|
||||
- 完整 `r102-reimbursement-main-flow.spec.ts` 为 5/6:本次新增用例通过;唯一失败为既有用例仍查找旧按钮名“编辑”,当前页面实际为“编辑报销日期”,与本次修改无关,未在本任务扩面修复。
|
||||
- 全量后端为 179 个测试文件通过、2 个失败,1295/1298 项通过;3 项失败均来自既有付款单 BIFF8 生成器在当前环境不可用,本任务未修改付款单代码。
|
||||
- process contract 与 Trellis governance 通过;docs registry 为 0 error、既有 22 个未登记文档 warning;敏感内容检查与 `git diff --check` 通过。
|
||||
@@ -0,0 +1,84 @@
|
||||
# 报销详情权限与材料预览缺陷修复
|
||||
|
||||
## Goal
|
||||
|
||||
修复报销只读查看者误看到“撤回报销”操作,以及带 EXIF 方向信息的图片进入材料合并预览后页面尺寸错误的问题;同时对 `BX-20260828-0017` 的首张图片未正常显示现象做受控、脱敏、只读诊断,让财务只看到职责内动作,并让图片材料在合并 PDF 中保持正确方向和比例。
|
||||
|
||||
## Background
|
||||
|
||||
- PC 报销详情已经计算 `isReimbursementParticipant`,但调用 `ReimbursementActions` 时未传 `showWithdrawAction`;该组件默认允许在 `reviewing` 状态显示撤回,因此具备全量查看权限的财务人员也会看到撤回入口。
|
||||
- 服务端撤回接口仍按 owner/submitter 做对象资格校验,非参与人会被拒绝;当前问题是前端动作资格投影错误,不是服务端越权或数据损坏。
|
||||
- 移动报销详情当前没有撤回操作,本轮只修复存在缺陷的 PC 入口,不增加移动操作。
|
||||
- 材料合并服务先对图片执行 Sharp `rotate()` 自动应用 EXIF 方向,但页面尺寸读取自旋转前 metadata。方向为 6/8 的手机图片会出现“输出像素已转向、PDF 页面仍按原宽高”的不一致。
|
||||
- 现有合并预览按发票在前、补充附件在后排序;`BX-20260828-0017` 有两张 PDF 发票和两张 JPG 补充附件。此前只读核查已确认 JPG 实际为发票图片,但未打开或持久化原件,尚不能仅凭附件列表断定“首张图片未显示”的最终技术原因。
|
||||
- 现行业务规则要求:只读审批人不得看到编辑、解绑、删除等数据变更动作;图片各占合并 PDF 一页,合并失败时保留逐份原件查看路径。
|
||||
|
||||
## Scope
|
||||
|
||||
- 修复 PC 报销详情中 owner/submitter 与财务只读查看者的撤回动作可见性。
|
||||
- 修复服务端材料合并时 EXIF 自动旋转后仍沿用输入尺寸的问题,并更新合并器版本。
|
||||
- 使用合成数据覆盖前后端回归;对 `BX-20260828-0017` 仅执行受控、脱敏、生产只读诊断。
|
||||
- 保持服务端授权、审批状态机、材料顺序、原件查看与失败降级合同不变。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 PC 撤回动作资格投影
|
||||
|
||||
- PC 报销详情仅在当前用户是报销单 owner 或 submitter 且状态为 `reviewing` 时显示“撤回报销”。
|
||||
- 财务、审批人、管理员等因 `expense.view_all` 或当前审批职责获得只读详情访问时,不得看到撤回输入框或按钮。
|
||||
- owner/submitter 在 `reviewing` 状态下继续看到并可使用现有撤回入口;草稿、驳回、已通过、已支付等既有状态行为不变。
|
||||
- `ReimbursementActions.showWithdrawAction` 必须改为无默认值的必填 prop,让遗漏资格投影在 TypeScript 编译期失败,不保留 fail-open 默认行为。
|
||||
- 服务端撤回资格校验、权限矩阵、审批状态机和审计合同保持不变,前端不能把隐藏按钮当作安全边界。
|
||||
|
||||
### R2 EXIF 图片合并尺寸修复
|
||||
|
||||
- 图片经 Sharp 自动旋转、扁平化和 JPEG 规范化后,必须使用实际输出的宽高创建 PDF 页面和绘制图片,不得使用旋转前 metadata。
|
||||
- 方向为 6/8 的合成 JPEG 应在输出 PDF 中保持正确的纵横方向和比例;无 EXIF 或无需旋转的 PNG/JPG/WebP 行为保持不变。
|
||||
- 继续执行现有图片像素、页数、输出大小、确定性字节、整包失败和逐份原件降级合同;不引入新依赖、不把合并件当作原件。
|
||||
- 合并算法的可见输出发生变化后应更新 merger version,避免新旧算法产物共用同一集合缓存身份。
|
||||
|
||||
### R3 `BX-20260828-0017` 首图只读诊断
|
||||
|
||||
- 在实施获批后,使用既有受控生产只读访问,确认材料实际顺序、MIME、字节/像素元数据、EXIF 方向、合并页数和对应页尺寸;现有事实已将用户所称“第一张图”锁定为合并 PDF 第 3 页,诊断从该页开始,仅在必要时生成短生命周期的本地临时派生预览。
|
||||
- 诊断不得写生产数据库、报销、审批、付款、对象存储或企业微信;不得把原始票据、图片、完整票号、存储位置、签名 URL、原始 payload 或凭据写入仓库、任务文档或聊天。
|
||||
- 临时原件或渲染页若确有必要,只能放在明确的安全临时目录,诊断完成即删除;交付只记录脱敏元数据和结论。
|
||||
- 若证据确认问题由 EXIF 尺寸错误造成,使用 R2 修复覆盖;若属于 PDF `AcroForm`、可选内容组、损坏源文件或其他超出当前图片合并边界的原因,只形成证据并返回规划,不临时替换 PDF 引擎或扩大任务。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] 复用现有 `adminMe + ownerId=employee-other-r102 + status=reviewing + scope=all` 合成场景;管理员作为非参与只读查看者打开报销详情时,页面不存在 `reimbursement-withdraw` 和撤回意见输入框。
|
||||
- [x] 同一状态下,合成 owner/submitter 仍能看到现有撤回入口;其他状态的既有动作投影不变。
|
||||
- [x] 服务端非 owner/submitter 撤回拒绝合同保持通过,未修改权限矩阵、状态机、工作流或数据库结构。
|
||||
- [x] 带 EXIF orientation 6 和 8 的合成图片合并后,PDF 页面宽高与规范化输出一致,方向和比例正确;普通横图、普通竖图和 PNG/WebP 回归通过。
|
||||
- [x] 相同有序材料在同一 merger version 内重复生成时字节完全一致;跨 merger version 允许因修复产生不同字节。材料顺序、页数上限、整包失败和原件降级合同不变。
|
||||
- [x] merger version 随算法修复更新,新的集合缓存身份不会复用旧算法产物。
|
||||
- [x] `BX-20260828-0017` 首图形成一份不含原件和敏感字段的只读诊断结论,明确材料顺序、问题类别、是否由 EXIF 修复覆盖,以及未覆盖时的下一步。
|
||||
- [x] 前端 typecheck/build、后端聚焦 Vitest/语法检查、相关 Playwright、Trellis 治理和 `git diff --check` 通过。
|
||||
- [x] 未经另行批准不部署公网;部署与公网检查完成前不告知用户线上可测试。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 财务审批页一页式重构、按钮“处理”的改名或待办信息结构调整。
|
||||
- 费用类型、会计科目、发票—明细关联、票面金额差异门禁、多票拆分、图片发票上传提示或附件用途治理;这些属于 Task B。
|
||||
- 修改、撤回、退回、审批或付款任何真实报销;重分类或重新上传 `BX-20260828-0017` 的图片附件。
|
||||
- 修改服务端撤回授权、角色/权限矩阵、审批规则、状态机、schema、迁移或生产配置。
|
||||
- 引入 PDF.js、Poppler、新 PDF 引擎,或在未重新规划时处理 PDF 表单域/可选内容组兼容。
|
||||
- 修改 `ReimbursementActions` 现有撤回默认说明文案“申请撤回并补充说明后重提”。
|
||||
- 对 `BX-20260828-0017` 执行真实单据处置,包括退回、例外接受、重传 PDF、重分类附件或修正票面合计;该单据应按现行规则另行作出业务处理决定。
|
||||
- 公网部署、生产写入、企微写入和真实付款。
|
||||
|
||||
## Risks and Constraints
|
||||
|
||||
- 隐藏动作只能改善正确性和体验,安全仍由服务端对象资格校验负责。
|
||||
- EXIF 修复可以解决旋转后尺寸不一致,但不能预先证明 `BX-20260828-0017` 的首图问题只有一个原因。
|
||||
- 真实材料含敏感信息,诊断必须最小读取、只保留脱敏摘要,并对临时文件 fail closed。
|
||||
- 现有视觉回归夹具中 owner 视角明确期待撤回按钮可见,修复不能简单删除该操作;需要新增非参与高权限查看者断言。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 后端聚焦:`cd server && npx vitest run tests/unit/services/reimbursementMaterialPreview.test.js tests/unit/routes/reimbursements.attachments.test.js`。
|
||||
- 后端基础:`cd server && node --check src/services/reimbursementMaterialPdf.js && node --check src/services/reimbursementMaterialPreview.js && npm run prisma:validate`。
|
||||
- 前端:`cd client && npm run typecheck && npm run build`。
|
||||
- Playwright:复用 `r102-reimbursement-main-flow.spec.ts` 的 admin 非参与人全范围夹具增加撤回不可见断言,并保留现有 owner 可见断言;route mock 场景继续阻断 Service Worker。
|
||||
- 只读诊断:以脱敏元数据核对 `BX-20260828-0017` 材料顺序、EXIF/像素和合并页尺寸,不保留真实材料证据。
|
||||
- 治理:`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/audit-docs.mjs --check-registry`、`node scripts/qa/check-trellis-governance.mjs`、`git diff --check`。
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "reimbursement-detail-material-preview-fixes",
|
||||
"name": "reimbursement-detail-material-preview-fixes",
|
||||
"title": "报销详情权限与材料预览缺陷修复",
|
||||
"description": "修复财务只读查看报销时误显示撤回操作、EXIF 图片材料合并尺寸错误,并对 BX-20260828-0017 首图异常做脱敏只读诊断。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client reimbursement action projection; server material PDF image normalization; production read-only preview diagnostics",
|
||||
"package": null,
|
||||
"priority": "P0",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "46bfa7e",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-28-reimbursement-detail-material-preview-deploy",
|
||||
"08-28-reimbursement-nonparticipant-readonly-projection"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "任务范围内聚焦验证通过。完整 r102 为 5/6,唯一失败是既有用例仍查找已不存在的‘编辑’按钮;本任务新增的管理员撤回不可见断言通过,不把该历史失败静默视为绿色基线。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
# 设计:非参与人报销详情只读投影修复
|
||||
|
||||
## 设计边界
|
||||
|
||||
本任务修正“访问资格”和“维护资格”被混用的问题,不新建报销详情页面、不重构审批工作台,也不改变角色/权限码。详情读取继续支持 owner/submitter、当前审批职责和 `expense.view_all`;报销人自助写入必须额外通过对象参与资格。
|
||||
|
||||
## 资格模型
|
||||
|
||||
```text
|
||||
可查看详情
|
||||
= owner/submitter 的 expense.view_own
|
||||
OR 当前审批/职责访问
|
||||
OR expense.view_all
|
||||
|
||||
可维护报销
|
||||
= 当前用户是 owner/submitter
|
||||
AND 当前状态允许该动作
|
||||
AND 具备对应自助权限码
|
||||
|
||||
职责动作
|
||||
= workflow 审批/退回、财务付款、付款批次等专用接口
|
||||
!= 报销人自助维护
|
||||
```
|
||||
|
||||
- 前端 PC 与移动端都从同一参与资格推导 `canMaintainReimbursement`,所有新增、编辑、删除、绑定、解绑、提交、撤回、催办和项目维护入口使用该事实。
|
||||
- 只读视图不渲染禁用的维护按钮;它保留可访问的事实信息和材料查看动作,并用“只读查看”标明当前上下文。
|
||||
- owner 与 submitter 都是参与人,支持代填/代提交的现有合同;其他角色不因 admin 或全量查看权限获得自助维护资格。
|
||||
|
||||
## 服务端数据流
|
||||
|
||||
```text
|
||||
请求权限码通过
|
||||
-> 锁定/读取目标报销
|
||||
-> 校验 actor.id == owner_id OR submitter_id
|
||||
-> 校验动作状态
|
||||
-> 执行业务写入与副作用
|
||||
```
|
||||
|
||||
- `reimbursements.js` 将 `ensureApplicantOrAdmin` 收紧为无 admin 豁免的参与人检查,统一用于报销头、删除、附件、明细和提交;`link-project` 补 `expense.edit_own`,从可查看检查改为参与人写检查,并增加 `draft/rejected` 状态门。
|
||||
- `invoicePool.js` 的 reimbursement bind/unbind 分支移除 `expense.view_all` 写入豁免,只保留 owner/submitter。travel order 分支继续按 `travel.payment.manage`,不受本任务影响。
|
||||
- 对象资格失败统一返回 `403 PERM_001`,并尽量使用稳定的 `only_owner_or_submitter` 详情码;测试断言事务内没有 update/create/delete、文件落盘、审计、workflow 或通知副作用。
|
||||
- `ensureOwnOrCanViewAll` 继续用于详情、附件原件和材料预览等读取接口,并补到会返回预算、费用标准和风险结果的 precheck;这些读取接口不改为参与人专属。
|
||||
- `expense.finance.pay` 和 workflow 路由是显式职责动作,保持原授权和状态机。
|
||||
|
||||
## 前端角色化呈现
|
||||
|
||||
### 参与人维护视图
|
||||
|
||||
- draft/rejected:保留报销头、明细、项目、发票、附件、提交和删除等现有动作。
|
||||
- reviewing:保留撤回和催办;不恢复草稿编辑动作。
|
||||
- 其他状态:沿用现有只读和付款结果呈现。
|
||||
|
||||
### 非参与人只读视图
|
||||
|
||||
- 概况区标明“只读查看”,下一步只描述状态事实,不给报销人操作指令。
|
||||
- 明细、材料对应情况、处理进度、风险、原件和合并预览继续可见。
|
||||
- 发票卡显示实际关联事实:`关联明细:...`、`已关联`;没有 `linkedItemId` 时明确为 `未关联`,不推断自动生成或匹配结果。`已关联` 适用于所有当前不可维护视角,不只限于非参与人。
|
||||
- 不渲染任何维护控件,避免出现按钮后再依赖 403 纠错。
|
||||
- PC 与移动端使用同一文案和资格含义;交互继续使用现有语义化 Button/Link、焦点样式和移动触控尺寸,不引入新的视觉系统。
|
||||
|
||||
## 兼容性与回滚
|
||||
|
||||
- 无 schema、migration、API payload 或角色定义变化;读取 DTO 不变。
|
||||
- 权限收紧会移除管理员/财务通过通用详情维护他人报销的旧行为,这是本任务的预期破坏性修正。历史数据不迁移,已经发生的审计记录不改写。
|
||||
- 回滚仅恢复本任务前后端代码;回滚会重新开放已确认的不安全资格,因此只有新版本造成更高等级阻断且获得明确回滚批准时才执行。
|
||||
- 本任务提交后仍不部署;部署子任务必须冻结新的候选 SHA,不能继续使用 `174cb7a`。
|
||||
|
||||
## 关键权衡
|
||||
|
||||
- 复用同一详情页面但切换角色化模式,避免复制 PC/移动两套详情和数据查询;资格合同通过共享事实和回归保证,不用 URL 区分安全边界。
|
||||
- 只隐藏动作不足以修复问题,因此同步收紧服务端对象资格。
|
||||
- 不给 admin 保留隐式后门。若未来确有代维护需要,应新建设计明确、带原因与审计的管理动作,而不是复用员工自助接口。
|
||||
- admin 豁免关闭后,离职/长期缺席员工的卡住草稿暂不提供代维护路径;真实需求由 Task B 的专用纠错动作或独立运营修复任务承接。
|
||||
- 本轮保留票据和明细事实展示,为财务核验提供上下文;一页式审批的信息结构和金额门禁留给 Task B。
|
||||
@@ -0,0 +1,54 @@
|
||||
# 实施清单:非参与人报销详情只读投影修复
|
||||
|
||||
## 1. 实施前门禁
|
||||
|
||||
- [x] 用户已审查并明确批准本 PRD、设计和实施清单;运行 `task.py start` 后再修改业务代码。
|
||||
- [x] 加载 `trellis-before-dev`,重读 frontend/backend/approval/reimbursement 规约和当前任务材料。
|
||||
- [x] 冻结工作区与基线;确认部署任务仍为 planning,未推送、未部署、未访问或写入生产。
|
||||
|
||||
## 2. 服务端对象资格
|
||||
|
||||
- [x] 将报销头、删除、附件、明细和提交路由的 `ensureApplicantOrAdmin` 收紧为 owner/submitter 参与人检查,保留原状态与权限码门禁。
|
||||
- [x] 为项目关联补 `expense.edit_own`,从 `expense.view_all` 可写收紧为 owner/submitter 可写,并增加 `draft/rejected` 状态门;不改变现有 feature flag 和 payload。
|
||||
- [x] 为 precheck 补 owner/submitter 或 `expense.view_all` 对象级读守卫;普通非参与人 403 且 precheck 服务零调用,合法查看者保持成功。
|
||||
- [x] 将 reimbursement 发票 bind/unbind 分支从 `isOwn || expense.view_all` 收紧为 owner/submitter;保持 travel order 分支不变。
|
||||
- [x] 统一 403 详情码并检查失败顺序,确保资格失败发生在数据、文件、审计、workflow 和通知副作用之前。
|
||||
|
||||
## 3. PC 与移动端动作投影
|
||||
|
||||
- [x] 建立统一的参与人/可维护资格事实,PC 和移动端都使用“参与人 AND 状态”控制维护动作。
|
||||
- [x] PC 收紧主要动作、明细增改删、项目选择、票据添加/移除、附件上传/删除、提交、删除草稿、撤回和催办。
|
||||
- [x] 移动端收紧 sticky submit、明细增改删、票据添加/移除、附件上传/删除、删除草稿、撤回和催办。
|
||||
- [x] 保留 owner/submitter 既有动作、财务/审批专用流程、材料查看和打印。
|
||||
|
||||
## 4. 只读文案与信息
|
||||
|
||||
- [x] 非参与人显示“只读查看”上下文,并把 draft/rejected 的下一步改为等待报销人处理的事实文案。
|
||||
- [x] `linkedItemId` 为空改为“关联明细:未关联”,无法解析改为“关联明细:无法识别”;移除“自动生成或匹配明细”。
|
||||
- [x] 当前不可维护视角的 bound 发票显示“已关联”,不渲染“草稿或驳回状态可移除”;参与人可维护视角保留必要操作说明。
|
||||
- [x] 检查 PC/移动无禁用的伪操作、无内部权限码或实现术语,继续使用语义化按钮/链接和现有焦点/触控合同。
|
||||
|
||||
## 5. 回归测试
|
||||
|
||||
- [x] 服务端合成矩阵按 S1/S2/S3 分档覆盖:admin、finance/`expense.view_all` 非参与人对 form/items/attachments/submit/project/invoice bind-unbind 的 403 与零副作用;approved/paid 项目关联为 409 且零写入。
|
||||
- [x] 服务端保留 owner/submitter 成功、GET/material preview 读取、workflow/finance pay 和 travel invoice 分支基线。
|
||||
- [x] Playwright 覆盖 PC/移动 nonparticipant draft 的无维护动作和文案,并保留 participant 主流程;rejected/reviewing 由相同资格投影与既有撤回/催办测试覆盖。
|
||||
- [x] 运行前端 typecheck/build、后端 node check/Prisma/聚焦测试及相关报销回归。
|
||||
|
||||
## 6. 复核、规约与提交
|
||||
|
||||
- [x] 按 `trellis-check` 复核跨层资格流,修复发现后重跑聚焦与全局门禁。
|
||||
- [x] 因涉及权限模型,实施完成后启动独立只读 review,不让 reviewer 修改代码;结论为 PASS,无权限阻断。
|
||||
- [x] 将“全量查看不等于对象维护、非参与人报销详情默认只读”的稳定合同更新到报销规约,避免再次 fail-open。
|
||||
- [x] 运行 task validate、process contract、Trellis governance、敏感检查和 `git diff --check`。
|
||||
- [x] 用户批准提交后,使用中文并引用 `08-28-reimbursement-nonparticipant-readonly-projection`;业务提交为 `e8d6766`,未推送、未部署、未归档。
|
||||
- [x] 提交完成后更新部署子任务候选依赖;完整 PRD 已明确固定回滚、迁移状态和公网角色检查,继续等待用户审查部署方案。
|
||||
|
||||
## 7. 实施发现与验证记录
|
||||
|
||||
- `link-project` 原实现调用未定义的 `parseBody`,feature flag 打开后会在写入前返回 500,因此其错误的 `expense.view_all` 写授权历史上不可实际利用。本轮在同一路由范围内改为专用 Zod 解析函数,测试覆盖 draft 参与人 200、非参与人 403、approved/paid 409。
|
||||
- r102 完整文件仍为 5/6:唯一失败保持在 `spec.ts:412`,根因仍是 travel category 夹具使 `showDescription=false`,与本轮 diff 无关;本轮新增的管理员 PC/移动只读投影用例通过。
|
||||
- 后端全量 Vitest 为 1303/1306;3 个失败均在既有付款单 BIFF8 生成链路,错误为 `payment_order_generator_unavailable`,本轮 diff 未触及付款批次或生成器。当前任务相关的聚焦与扩展套件均通过。
|
||||
- 独立只读权限复核结论 PASS:非参与人写入守卫位于副作用前,读取、workflow、财务付款和差旅专用分支未收紧。非阻断建议是后续补附件 DELETE 单独用例及 rejected/reviewing UI 状态矩阵。
|
||||
- 提交前复核发现 precheck 缺少对象级读授权;本轮决定直接修复。路由现在先读取报销并复用 `ensureOwnOrCanViewAll`,普通非参与人 403 且 precheck 服务零调用,owner/submitter 与 `expense.view_all` 保持成功。父 task `task.json` 末字节已再次确认为 `0a`。
|
||||
- 本轮没有 schema 或 migration 变化,没有访问生产、推送、部署或归档;部署任务继续处于 planning 并阻塞于本任务复核与提交决定。
|
||||
@@ -0,0 +1,100 @@
|
||||
# 非参与人报销详情只读投影修复
|
||||
|
||||
## Goal
|
||||
|
||||
把报销详情按对象参与资格收敛为两种明确模式:owner/submitter 在允许编辑的状态维护自己的报销;财务、审批人、管理员和其他因职责或 `expense.view_all` 获得访问权的非参与人只查看、核验材料和执行职责内的独立业务动作,不得复用报销人的维护入口或直接变更他人报销数据。
|
||||
|
||||
## Background
|
||||
|
||||
- `ReimbursementDetailPage.tsx:725` 和 `MobileReimbursementDetailPage.tsx:206` 只按 `draft/rejected` 判断可编辑状态。PC 的新增/编辑/删除明细、项目关联、附件上传/删除和主要提交动作,以及移动端对应动作,没有全部叠加 owner/submitter 资格。
|
||||
- PC 与移动端的 `canUnbindInvoices` 都把 `expense.view_all` 当成他人报销的解绑资格;`invoicePool.js:1082` 的服务端解绑合同也允许该权限直接解除他人报销与发票的关联,因此不是单纯的前端误显。
|
||||
- `ensureApplicantOrAdmin` 允许管理员修改、删除、提交他人草稿,新增/修改/删除他人明细和附件;`link-project` 与发票 bind 也把全量查看权限当成写入资格。全量查看、系统管理或当前审批职责不等于报销人自助维护资格。
|
||||
- 发票卡片在所有视角共用 `resolveInvoiceNextStepText`,向非参与人显示“草稿或驳回状态可移除”;`linkedItemId` 为空时又显示技术性兜底“自动生成或匹配明细”。这些文案不是只读核验事实。
|
||||
- 现有事实源要求页面按当前任务角色收敛,审批人和财务只看到职责内动作;报销材料仍允许授权查看原件和合并预览。Task A 已修复审批中撤回入口,但没有覆盖更广的草稿/驳回维护动作和服务端对象资格。
|
||||
|
||||
## Defect Severity
|
||||
|
||||
- **S1 真实写入能力**:reimbursement 发票 unbind/bind 把 `expense.view_all` 当成对象写资格;`ensureApplicantOrAdmin` 允许 admin 修改、删除、提交他人报销头、明细和附件。`link-project` 的授权设计同样错误(仅认证、复用读守卫且没有状态门),但原实现还调用了未定义的 `parseBody`,因此 feature flag 打开后会在写入前 500,历史上该写入洞不可实际利用;本轮仍须同时修复可用性和授权边界。测试必须断言 403/409 及零副作用,不能只检查按钮不可见。
|
||||
- **S2 仅显示或前后端不一致**:PC 多处明细/附件动作和移动端对应动作只按 editable status 渲染,普通财务可能看到按钮但被部分服务端路由拒绝。测试必须断言 PC/移动 DOM 中不存在无资格动作,并保留直接 API 负向回归。
|
||||
- **S3 已有正确边界**:PC/移动报销头编辑器已经同时检查 editable status 和参与人资格;实施只统一资格事实并证明基线,不重复改写已经正确的组件合同。
|
||||
|
||||
## Scope
|
||||
|
||||
- 统一 PC、移动端的报销参与人和可维护资格计算,修正所有报销人自助维护动作的投影。
|
||||
- 收紧报销与发票路由中把 admin 或 `expense.view_all` 当成他人报销维护资格的服务端分支。
|
||||
- 为报销 precheck 补对象级读取授权,避免普通员工横向读取他人预算、费用标准和风险结果。
|
||||
- 为非参与人提供明确的只读查看语义和事实型票据文案,同时保留授权材料查看、打印、审批和财务专属流程。
|
||||
- 使用合成身份和数据覆盖 owner/submitter、财务、管理员及普通非参与查看者,不读取或修改真实报销。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 服务端对象资格必须 fail closed
|
||||
|
||||
- 报销 owner 或 submitter 统称参与人。只有参与人可通过报销人自助接口修改报销头、删除草稿、上传/删除附件、新增/修改/删除明细、提交报销、关联项目,以及为该报销绑定或解绑发票;状态和对应权限码仍需同时满足。
|
||||
- `POST /reimbursements/:id/link-project` 必须补 `expense.edit_own` 权限门,只允许参与人在 `draft/rejected` 状态写入;非参与人返回 403,已通过、已支付等非编辑状态返回 409。不得继续使用 `ensureOwnOrCanViewAll` 读守卫授权写入。
|
||||
- admin、财务、审批人及任何仅持 `expense.view_all` 的非参与人调用上述接口时必须稳定返回 `403 PERM_001`,并在读取目标对象后、任何业务写入、文件持久化、审计或通知副作用前失败。
|
||||
- `expense.view_all` 继续允许授权查看报销详情、发票/附件原件和材料合并预览;不得把读权限降级为无权查看。
|
||||
- `POST /reimbursements/:id/precheck` 虽不写业务数据,但会返回预算、费用标准、审批规则和风险结果,必须先读取目标报销并通过 owner/submitter 或 `expense.view_all` 对象级读守卫;普通非参与人返回 403,且不得调用 precheck 服务。审批页既有 `standardViolations` 快照继续保留。
|
||||
- 显式职责动作保持独立:workflow 审批/退回、财务付款及付款批次接口不改;差旅订单 bind/unbind 分支不改。若未来需要财务纠正票据归属,必须在 Task B 设计专用、可审计的业务动作,不能继续借用报销人解绑接口。
|
||||
- 不改变状态机、权限码集合、角色定义、数据库 schema 或迁移。
|
||||
|
||||
### R2 PC 与移动端统一维护资格
|
||||
|
||||
- 页面使用“参与人 AND 状态允许编辑”计算统一的 `canMaintainReimbursement`,不得再把 `draft/rejected` 或 `expense.view_all` 单独当成维护资格。
|
||||
- 非参与人不得看到或触发:新增/编辑/删除明细、修改报销日期/事由/差旅摘要、关联项目下拉框、添加/移除发票、上传/删除附件、提交报销、删除草稿、撤回、催办等报销人自助动作。
|
||||
- owner/submitter 在 draft/rejected 状态继续看到并可使用既有维护动作;owner/submitter 在 reviewing 状态继续使用 Task A 已修复的撤回和催办合同,其他状态行为不变。
|
||||
- 只读查看者可继续查看报销概要、明细事实、处理进度、材料原件/合并预览、风险与材料对应情况,以及权限允许的打印入口;页面不通过禁用按钮暗示不可用动作,而是完全不渲染无资格动作。
|
||||
- PC 和移动端必须使用同一资格语义,不能只修截图对应的 PC 页面。
|
||||
|
||||
### R3 非参与人使用事实型查看文案
|
||||
|
||||
- 同一详情路由可以复用,但非参与人应显示明确的“只读查看”上下文,不伪装成报销人维护页,也不新建一套重复详情页面。
|
||||
- 非参与人的“下一步”按当前业务状态表达观察事实:草稿为“等待报销人完善”,驳回为“等待报销人修改后重提”,审批中显示当前处理事实,已通过为“等待财务处理”,已支付为“查看付款结果”。不得提示其新增明细、添加发票或提交审批。
|
||||
- 发票存在真实 `linkedItemId` 时显示 `关联明细:<费用类型 · 明细说明>`;为空时显示 `关联明细:未关联`,关联对象无法解析时显示 `关联明细:无法识别`。不得再显示“自动生成或匹配明细”。
|
||||
- bound 发票在当前不可维护视角(包括非参与人,以及 owner/submitter 查看 approved/paid 等非编辑状态)使用事实状态“已关联”,且不显示“草稿或驳回状态可移除”等操作提示。只有参与人的可维护视角可保留必要的移除说明。
|
||||
- 材料对应、票面金额、日期和风险提示继续作为财务/审批核验信息展示;本轮不重构为 Task B 的一页式审批工作台。
|
||||
|
||||
### R4 回归与可部署基线
|
||||
|
||||
- 前端回归至少覆盖 PC 和移动端的非参与管理员/财务查看他人 draft、rejected、reviewing 报销,断言全部维护动作不存在、只读文案正确、原件和合并预览入口仍可用。
|
||||
- owner/submitter 合成场景继续覆盖 draft/rejected 维护动作和 reviewing 撤回,防止把页面整体改成只读。
|
||||
- 服务端对每类维护接口至少覆盖一个非参与高权限身份的 `403` 和零副作用,并保留参与人成功基线;发票 bind/unbind 同时保留差旅分支现有合同。
|
||||
- 权限与审批规则改动实施后必须完成独立只读复核;未经复核和新工作提交,不更新部署候选、不推送、不部署。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 非参与管理员或财务查看他人 draft/rejected 报销时,PC 和移动端不存在明细、项目、票据、附件、提交、删除、撤回和催办等报销人维护动作。
|
||||
- [ ] 同一非参与人仍能查看报销概要、明细事实、处理进度、材料原件和合并预览;reviewing 审批/财务职责入口仍在原工作流页面正常使用。
|
||||
- [ ] 非参与人看到“只读查看”上下文;未关联票据显示“关联明细:未关联”,bound 状态显示“已关联”,页面不存在“自动生成或匹配明细”和“草稿或驳回状态可移除”。
|
||||
- [ ] 非参与人直接调用报销头修改/删除、附件增删、明细增改删、提交、项目关联、发票 bind/unbind 均返回 `403 PERM_001`,且对应数据、文件、审计、workflow 和通知零副作用。
|
||||
- [ ] 普通非参与人调用 precheck 返回 `403 PERM_001` 且 precheck 服务零调用;owner/submitter 与 `expense.view_all` 查看者保持成功读取基线。
|
||||
- [ ] owner/submitter 在 approved/paid 等非编辑状态调用 `link-project` 返回 409 且项目归属不变;draft/rejected 且具备 `expense.edit_own` 时保持成功基线。
|
||||
- [ ] owner/submitter 在既有允许状态仍可维护、提交、撤回和催办自己的报销;现有状态校验、材料查看、打印、财务付款、workflow 审批和差旅票据分支不变。
|
||||
- [ ] 无 schema、迁移、权限码或角色定义变更;前后端聚焦测试、前端 typecheck/build、相关 Playwright、Trellis 治理、敏感检查和 `git diff --check` 通过。
|
||||
- [ ] 只读权限复核结论为 PASS 后才形成新工作提交;部署子任务随后重新冻结包含 Task A 与本任务的候选 SHA,原 `174cb7a` 不再作为最终部署基线。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Task B 的财务一页式审批、费用口径、`requires_invoice`、票据—明细强制关联、金额差异门禁和多票拆分。
|
||||
- 为财务或管理员新增“代员工修改”“纠正票据归属”或其他管理修复入口。
|
||||
- 修改 workflow 审批/退回、付款批次、财务付款、材料查看授权、打印权限、发票 OCR 或材料合并算法。
|
||||
- 处理 `BX-20260828-0017` 的真实单据、票面差额、退回、付款或附件重传。
|
||||
- 修复 r102 差旅夹具测试债;该项仍按独立任务处理。
|
||||
- 推送、部署、公网验证、生产配置、生产数据写入和任务归档。
|
||||
|
||||
## Risks and Constraints
|
||||
|
||||
- 服务端移除 admin/`expense.view_all` 写入豁免属于真实权限收紧,必须用直接接口测试证明读取能力仍在、写入统一拒绝。
|
||||
- 不能只隐藏前端动作;否则旧客户端或直接 API 仍可变更他人报销。
|
||||
- 不能把所有非参与人操作一刀切:workflow 审批、财务付款和授权材料查看是职责动作,必须与报销人维护动作区分。
|
||||
- 移除 admin 通用写入豁免后,员工离职或长期缺席造成的卡住草稿暂时没有代维护入口;若真实发生,应在 Task B 的专用可审计纠错动作或独立运营修复任务中重新规划,本任务不以保留通用后门兜底。
|
||||
- 同一页面角色化比复制新页面更低风险,但 PC/移动端必须共享资格事实和文案合同,避免再次漂移。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 服务端语法与基础:`cd server && node --check src/routes/reimbursements.js && node --check src/routes/invoicePool.js && npm run prisma:validate`。
|
||||
- 服务端聚焦:新增或扩展报销参与人资格矩阵测试,覆盖 form/items/attachments/submit/project/invoice bind-unbind 的非参与高权限 403、零副作用和参与人成功基线。
|
||||
- 前端:`cd client && npm run typecheck && npm run build`。
|
||||
- Playwright:扩展 `r102-reimbursement-main-flow.spec.ts` 或新增聚焦 spec,使用阻断 Service Worker 的合成 PC/移动身份覆盖 participant 与 nonparticipant 的 draft/rejected/reviewing 投影及只读票据文案。
|
||||
- 全局回归:运行相关报销、材料预览和系统动作确认用例;完整 r102 的既有差旅夹具失败必须如实单列,不得把它当成本任务新失败或默认绿色基线。
|
||||
- 治理:`node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、`node scripts/qa/check-sensitive-content.mjs`、`git diff --check`。
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "reimbursement-nonparticipant-readonly-projection",
|
||||
"name": "reimbursement-nonparticipant-readonly-projection",
|
||||
"title": "非参与人报销详情只读投影修复",
|
||||
"description": "收紧非 owner/submitter 查看报销详情时的 PC、移动端操作投影与服务端变更资格,并改为面向审批/浏览者的只读票据文案。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "PC/mobile reimbursement read-only projection; reimbursement and invoice-pool object authorization; role-aware invoice copy",
|
||||
"package": null,
|
||||
"priority": "P0",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-28",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "e8d6766",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-28-reimbursement-detail-material-preview-fixes",
|
||||
"relatedFiles": [
|
||||
"client/src/pages/ReimbursementDetailPage.tsx",
|
||||
"client/src/pages/MobileReimbursementDetailPage.tsx",
|
||||
"client/src/lib/invoiceStatus.ts",
|
||||
"server/src/routes/reimbursements.js",
|
||||
"server/src/routes/invoicePool.js"
|
||||
],
|
||||
"notes": "实施与独立只读权限复核 PASS,业务提交为 e8d6766;同时补齐 precheck 对象级读授权。财务/审批详情中的行内费用标准提示当前不主动请求 precheck,由 Task B 的 B3 一页式审批恢复,现有 budgetHint、riskFlags 和审批页 standardViolations 快照不受影响。未推送、未部署、未归档,部署仍须单独批准。",
|
||||
"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,50 @@
|
||||
# 设计:报销费用口径与票据明细门禁
|
||||
|
||||
## 边界
|
||||
|
||||
Task B-1 合并交付费用主数据政策和提交门禁代码,但运行时分成两级:B1 数据模型/主数据/表单字段随部署生效,B2 强关联和金额阻断由默认关闭的双闸 feature flag 控制。B3 财务一页式另建任务,等待 B2 在真实或专用数据上验证后开始。
|
||||
|
||||
## 数据模型
|
||||
|
||||
- `ExpenseType.requires_invoice Boolean @default(true)`:用 migration 为既有类型回填 true,再定向把两项补助设为 false。
|
||||
- `ReimbursementItem.asset_prior_approval_basis String?`:仅 assets 明细要求,保存批准人、批准时间和简短依据;非 assets 明细应保持 null 或忽略空白值。
|
||||
- 不为 `account_code_default` 新增字段;财务提供映射前保持 null。重复执行 seed/init 的 update 分支不覆盖已有科目映射,避免后续部署抹掉财务回填。
|
||||
- 费用类型停用只改 `enabled`,不删除记录、不迁移历史明细外键。
|
||||
|
||||
## 费用类型迁移
|
||||
|
||||
| 当前类型 | 动作 | 新提交口径 | 历史口径 |
|
||||
| --- | --- | --- | --- |
|
||||
| `business_client_meal` 客户招待 | 原 code 改名 | 业务招待费 | 追溯显示新名称 |
|
||||
| `business_training` 培训会议 | 停用旧类型并新建两个 code | 会议费、培训费 | 保持培训会议 |
|
||||
| `assets_it_hardware` IT 硬件 | 停用旧类型并新建 code | 低值配件选办公用品;需登记的选择办公电子设备 | 保持 IT 硬件 |
|
||||
|
||||
新 code 在实施前按现有命名规则固定并写入 migration/seed 测试,不复用旧 code 承载不同语义。
|
||||
|
||||
## 写入和提交数据流
|
||||
|
||||
1. 员工建立或编辑明细,服务端读取权威 ExpenseType。
|
||||
2. assets 类型必须校验 `asset_prior_approval_basis`;前端提示只是辅助,服务端是最终门禁。
|
||||
3. 办公用品明细金额达到 `2,000 元`时生成非阻断类型提示,员工端和财务审核视图消费同一服务端事实;该阈值不作为会计资产确认结论。
|
||||
4. 发票上传/绑定显式携带目标明细 ID。B2 flag 开启时禁止无法唯一匹配后的自动建明细。
|
||||
5. 提交事务重新读取明细、有效发票及关联关系,按明细计算票面合计。
|
||||
6. `requires_invoice=false` 跳过票面比较;true 类型执行缺票和超票面阻断。
|
||||
7. flag 关闭只选择旧提交/匹配路径,关联表中的既有数据不做逆向写入。
|
||||
|
||||
## Feature Flag
|
||||
|
||||
- 新增独立 key,不复用 `budget_precheck_v1` 或 `project_linking_v1`。
|
||||
- 配置层新增 env hard gate,默认 false;服务白名单注册 key;seed/受控配置提供 DB `force_off` 初值。
|
||||
- 后续开启先使用专用账号或部门 allowlist(如现场组织结构允许),再全局开启。
|
||||
- flag 状态必须进入诊断/审计输出,但不向普通员工显示内部 key。
|
||||
|
||||
## 兼容和回滚
|
||||
|
||||
- schema 只做向前兼容的 nullable/default 字段新增,不在应用回滚时逆迁移。
|
||||
- 旧草稿保留;B2 开启后提交时补齐,不批量伪造关联或批准依据。
|
||||
- 应用异常时先关闭 B2 flag;这不会删除既有关系。B1 名称/字段回滚只通过新 migration 或后续主数据动作,不直接删列。
|
||||
- OCR 仍可给出候选匹配,但 B2 开启时最终绑定必须由用户确认,不能静默创建业务明细。
|
||||
|
||||
## 延后项
|
||||
|
||||
- PurchaseRequest 强关联、资产自动建卡、数量/单价、B3 一页式审批和财务科目表治理不混入本轮核心门禁。
|
||||
@@ -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. 规划门禁
|
||||
|
||||
- [x] 固定 B1+B2 同包开发、分级启用;B2 默认关闭。
|
||||
- [x] 选择 assets “事前批准依据”字段方案,不在本轮关联 PurchaseRequest。
|
||||
- [x] 用户批准以 `2,000 元/条明细`作为办公用品/办公电子设备选类提示阈值,并明确不等同于会计固定资产标准。
|
||||
- [x] 财务负责人为会计科目映射回填责任角色;应在 B1 生产启用前提供,目标日期 `2026-09-05`。届时仍无正式科目表则保持 null,不得由开发猜测。
|
||||
- [x] PRD、design、implement 经用户审查并明确批准实施。
|
||||
|
||||
## 2. Schema 与主数据
|
||||
|
||||
- [x] 新增 `ExpenseType.requires_invoice` 和 `ReimbursementItem.asset_prior_approval_basis` migration,验证既有数据默认值。
|
||||
- [x] 逐条实现改名、停用和新建类型;同步 seed、API DTO、管理页和历史兼容测试。
|
||||
- [x] 为费用类型补员工可读 description;将 `2,000 元/条明细`阈值和多件同类分开记载要求写入办公用品/办公电子设备提示。
|
||||
|
||||
## 3. B1 表单与服务端合同
|
||||
|
||||
- [x] PC/移动端展示材料要求和 assets 事前批准依据字段。
|
||||
- [x] 服务端按权威费用类型校验 assets 字段,覆盖新增、编辑、类型切换后的清理和提交复核。
|
||||
- [x] 基于服务端权威费用类型与金额生成 `2,000 元`办公用品非阻断提示,PC 员工端、移动端和财务审核视图保持一致。
|
||||
- [x] 两项补助保持无票但需要说明/算式的现有业务合同;flag 开关两种状态均覆盖纯补助无票基线。
|
||||
|
||||
## 4. B2 双闸和关联
|
||||
|
||||
- [x] 新增 env config、feature key、DB 初始记录和诊断/审计测试,默认 hard-off/force-off。
|
||||
- [x] flag 开启时要求发票显式选择明细并关闭自动新建;关闭时保持兼容路径。
|
||||
- [x] 提交事务按明细聚合有效票面金额,仅对 `requires_invoice=true` 执行缺票/超额门禁。
|
||||
- [x] 错误返回可定位到明细并使用员工可理解文案;财务保留差异和风险核验信息。
|
||||
|
||||
## 5. 验证与上线
|
||||
|
||||
- [x] 执行 PRD Test Plan 和 Trellis quality gate,做权限/状态/历史草稿/flag 回退专项复核。
|
||||
- [x] B1 部署与真实单据处置顺序已调整:先部署 B1、隔离验证并启用 B2,再由真实财务统一退回 `BX-20260828-0014/0016/0017/0018`,避免员工在无系统指引时重复返工。
|
||||
- [x] 已只读核对生产存量:除审批中的 4 张单据外,没有 draft/rejected assets 明细会被 B1 新校验拦住;不得批量伪造事前批准依据。
|
||||
- [ ] 首次部署只启用 B1;B2 保持关闭,完成 owner 基线和无票补助验证。
|
||||
- [ ] B2 开启必须单独获得生产配置批准;先专用账号/部门灰度,再按证据决定全局。
|
||||
- [ ] 关闭 B2 后复核旧提交路径恢复且关联数据未被删除或解绑。
|
||||
|
||||
## 6. 交接 B3
|
||||
|
||||
- [ ] 汇总真实关联、差额和错误分布的脱敏结论,作为 B3 一页式信息结构输入。
|
||||
- [ ] 明确交接财务行内费用标准提示、票据—明细对应关系和差额解释,不在 B1 中提前重做审批页面。
|
||||
|
||||
## 7. 本地实施与验证记录(2026-08-29)
|
||||
|
||||
- 业务实现提交:`6e999d5`。
|
||||
- Schema、B1、B2 代码均已实现;B2 env hard gate 和 DB 初值仍为关闭,未执行任何生产配置写入。
|
||||
- `prisma validate`、`prisma generate`、generated client 检查通过;只读 `prisma migrate status` 正确识别待应用 migration `20260829120000_reimbursement_expense_policy_invoice_guard`。本地回归库未获 migration 写入批准,因此未执行 `migrate deploy`;existing-data/default/backfill 由静态 migration 合同测试覆盖。
|
||||
- 后端聚焦回归 `59/59` 通过;使用固定 BIFF8 Python 依赖的全量 Vitest 为 `183/183 files`、`1329/1329 tests` 通过。系统 Python 直接运行会产生既有的 3 个付款指令环境型失败,不属于本次 diff。
|
||||
- 前端 `typecheck`、production build 通过;r102 普通报销回归 `6/6`,r111 B1/B2/PC/移动/企业微信回归 `3/3` 通过。
|
||||
- Trellis task validate、governance、process contract、docs registry audit 和 `git diff --check` 通过;docs audit 保留既有的 `22` 个未注册文件 warning,exit 0。
|
||||
- 权限、migration、feature flag、历史兼容和 seed 科目保护已完成三轮只读专项复核,最终 PASS。复核发现并推动修复:pending OCR 关联丢失、flag-off 纯无票补助服务端门禁、PC 无票补助主操作投影。
|
||||
- 未部署 B1、未应用生产 migration、未开启 B2、未做真实数据写入;本 task 保持 `in_progress`,上线与 B3 交接项不得提前勾选。
|
||||
- 生产只读审计把业务处置范围扩大为 4 张审批中单据:0014 为 5 个发票日期压成 1 条明细且 B2 无法自动拦截;0016/0017/0018 存在未关联票据,0017 另有票面差额。最终顺序改为先部署 B1、隔离验证并全局启用 B2,再由真实财务统一退回,日期粒度继续依赖明确退回说明与人工复核。部署与灰度合同见子任务 `08-30-reimbursement-expense-policy-invoice-guard-deploy`。
|
||||
@@ -0,0 +1,99 @@
|
||||
# 报销费用口径与票据明细门禁
|
||||
|
||||
## Goal
|
||||
|
||||
把现有自创或含糊的费用类型收敛为财务可理解的企业费用口径,明确发票要求和 assets 个人报销例外条件;同时建立可核验的票据—明细关系与申报金额门禁,避免多日期、多支出、多张发票被合并为无法复核的一条笼统明细,并通过默认关闭的双闸门保护现有唯一真实报销链路。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- `ExpenseType` 已有 `enabled`、`description`、`account_code_default`,但没有 `requires_invoice`;本任务必须新增 migration。
|
||||
- 当前种子费用类型的 `account_code_default` 全部为空,不能由开发人员自行编造会计科目编码。
|
||||
- 现有 feature flag 是 env、`FEATURE_FLAG_KEYS` 白名单和 DB 记录三件套;env 关闭时 DB `force_on` 也不能生效。
|
||||
- 当前多明细发票匹配允许金额无法唯一匹配时自动创建明细;这与“员工明确选择明细、每张有效票据可核验”目标冲突。
|
||||
- 伙食补助费和市内交通补助费按天包干且不要求发票;数量/天数乘算能力不在本任务内。
|
||||
- assets 类当前只提示报销完成后人工登记资产,没有“事前批准”结构化证据。
|
||||
|
||||
## Scope
|
||||
|
||||
- B1:费用类型 schema、历史兼容主数据、员工可读说明、`requires_invoice` 与 assets 事前批准依据。
|
||||
- B2:明确票据—明细关系、停止静默自动建明细、按需票明细执行金额门禁,以及默认关闭的分级启用机制。
|
||||
- PC、移动端、服务端、Prisma migration、种子/配置、测试与现有业务事实源同步纳入同一交付合同。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 费用类型与历史兼容
|
||||
|
||||
- `business_client_meal` 保持 code 不变,将名称从“客户招待”改为“业务招待费”;历史单据追溯显示新名称,视为同一概念的术语纠正。
|
||||
- 停用 `business_training`“培训会议”,新建“会议费”和“培训费”两个独立类型;历史单据继续显示原类型,新报销不能再选择已停用类型。
|
||||
- 停用 `assets_it_hardware`“IT 硬件”;低值配件使用既有“办公用品”,需登记资产的办公电子设备使用新类型“办公电子设备”。历史单据保持原类型名称。
|
||||
- 停用均复用 `enabled=false` 模式,不删除类型、不重写历史明细外键。
|
||||
- 逐项补充面向员工的 `description`,说明适用范围和必要材料;不得把内部 category/code 当作员工文案。
|
||||
|
||||
### R2 发票要求与会计科目
|
||||
|
||||
- `ExpenseType` 新增 `requires_invoice`,默认 `true`;“伙食补助费”和“市内交通补助费”明确为 `false`,其余首期启用类型为 `true`。
|
||||
- `requires_invoice=false` 只免除发票要求,不免除业务说明、补助算式、审批或其他必要附件。
|
||||
- `account_code_default` 不填猜测值。财务负责人应在 B1 生产启用前提供现行会计科目映射;若届时仍无法提供,本任务保留 null 并在同一 task 留下具名责任角色、缺口和回填日期,不以虚构编码阻塞材料门禁。
|
||||
|
||||
### R3 assets 个人报销例外
|
||||
|
||||
- assets 类允许个人报销,但属于经事前批准的例外,不作为普通日常采购入口。
|
||||
- 采用低成本方案:`ReimbursementItem` 增加单一结构化“事前批准依据”字段;选择 assets 类时必须填写批准人、批准时间和可核验的简短依据,服务端在保存和提交时校验,PC/移动端合同一致。
|
||||
- 采购申请关联不纳入本任务;未来如需替代自由文本,另行设计与 `PurchaseRequest` 的审计关联。
|
||||
- 员工按单条明细金额区分“办公用品”和“办公电子设备”:`2,000 元`及以上通常选择“办公电子设备”,低于 `2,000 元`通常选择“办公用品”。该阈值只用于内部选类和资产登记提示,不等同于会计固定资产确认标准。
|
||||
- 阈值同时写入费用类型 `description` 和表单提示;在数量/单价模型暂缓期间,一条明细应是一件或一批同日、同类、同一业务支出的物品,多件同类物品应分开记载,以便按本条明细金额判断。
|
||||
- 明细金额达到 `2,000 元`但选择“办公用品”时,员工端和财务审核视图均显示非阻断提示:“该金额通常应选择办公电子设备并填写事前批准依据”;提示不得自动改类型或阻断提交,财务仍按实际物品性质复核。
|
||||
|
||||
### R4 票据—明细强关联
|
||||
|
||||
- 每张有效发票必须显式关联到一条费用明细;员工在上传/绑定时明确选择目标明细。
|
||||
- B2 启用后关闭“无法唯一匹配时自动新建明细”;服务端不得在前端要求明确选择的同时继续静默建明细。
|
||||
- 一条明细只表达同一费用类型、同一发生日期和同一业务支出;采购物品还应是一件或一批同类物品。不同日期、不同支出或需要分别判断 `2,000 元`设备提示的多件物品必须拆分。多张同日、同类型且同一业务支出的发票可以关联同一明细。
|
||||
- 发票、明细及关联变更保留既有审计和权限边界;非参与人不得借新入口维护他人报销。
|
||||
|
||||
### R5 申报金额门禁
|
||||
|
||||
- 仅对 `requires_invoice=true` 的明细比较申报金额与其关联有效发票票面合计。
|
||||
- 无票补助不参与票面金额比较,不得因票面合计为 0 阻断正数补助。
|
||||
- 需票明细未关联有效发票,或申报金额高于关联票面合计时,B2 启用状态下阻断提交并返回逐条可操作提示。
|
||||
- 申报金额低于票面合计允许提交,但必须保留现有差异说明/财务核验信息;退款、折扣、他人承担等不得重复报销。
|
||||
- 历史草稿不批量改写;在 B2 开启后再次提交时按新门禁补齐关联、差异说明和 assets 批准依据。
|
||||
|
||||
### R6 分级启用与回退
|
||||
|
||||
- B1 schema、主数据、字段和展示随代码部署生效,但不得主动阻断不涉及 assets 的既有提交路径。
|
||||
- B2 的自动建明细关闭、强关联和金额提交门禁由新增 feature flag 控制,必须接入 env 配置、`FEATURE_FLAG_KEYS` 白名单和 DB 记录,初始保持 env hard-off / DB force-off。
|
||||
- 开启前必须满足:owner 正常提交基线;两项无票补助不被阻断;多票关联正确;申报金额超票面能准确阻断;历史草稿可补齐后提交;关闭 flag 能立即恢复旧提交门禁;合成或专用账号验证无越权副作用。
|
||||
- 关闭 flag 只回退提交门禁和自动匹配行为选择,不删除、不解绑、不回退已经建立的票据—明细关联数据。
|
||||
- B2 的生产开启属于独立配置写入,必须单独批准并完成角色化公网检查。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] migration 安全新增 `ExpenseType.requires_invoice` 和明细事前批准依据字段,既有数据有确定默认值且 Prisma validate/status 通过。
|
||||
- [x] 费用类型逐条按“改名”或“停用+新建”执行;历史单据无丢失,新建/编辑列表不显示停用类型。
|
||||
- [x] assets 明细在 PC、移动端和服务端均强制填写事前批准依据,提示明确 `2,000 元/条明细`是选类门槛而非会计固定资产标准。
|
||||
- [x] 办公用品明细金额达到 `2,000 元`时,员工端和财务审核视图均显示非阻断类型不一致提示,提交仍可继续且服务端不自动改类。
|
||||
- [x] 两项补助 `requires_invoice=false`,其他首期启用类型为 true;无票补助可提交且不会触发票面金额门禁。
|
||||
- [x] B2 flag 三件套完整、默认关闭;关闭状态保持当前提交兼容,开启状态关闭自动建明细并执行强关联/金额门禁。
|
||||
- [x] 每张有效发票关联一条明确明细,不同日期/支出必须拆分;同日同类型同一支出允许多票关联。
|
||||
- [x] 需票明细缺票或申报高于关联票面合计时逐条阻断,低于票面时保留差异核验信息。
|
||||
- [x] flag 关闭后既有关联数据保持不变,不进行数据回滚。
|
||||
- [x] owner、submitter、财务/管理员只读边界和 `precheck` 对象授权不回归。
|
||||
- [ ] 财务科目映射由财务负责人提供并在 task 内记录回填结果;没有正式科目表时保持 null,不虚构编码。
|
||||
- [x] 聚焦单测、API 集成、PC/移动 Playwright、Prisma validate、前后端类型/构建、Trellis governance 和 migration 静态/只读检查通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 数量、天数、单价自动乘算和差旅主线扩建。
|
||||
- B3 财务一页式审批工作台及其行内费用标准提示恢复。
|
||||
- 强制关联采购申请、自动建资产卡、会计凭证、总账或 ERP 对接。
|
||||
- 处理 `BX-20260828-0017` 真实单据或其他生产报销。
|
||||
- 在本任务部署授权中推定 B2 flag 开启授权。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Prisma:migration review、`npm run prisma:validate`、existing-data migration fixture、pre/post migration status。
|
||||
- 后端:费用类型映射、assets 字段校验、bind/submit 强关联、金额边界、无票补助、flag on/off、历史草稿和权限负例。
|
||||
- 前端:PC/移动费用类型提示、事前批准依据、明确选择明细、逐条错误展示和非参与人只读回归。
|
||||
- 回归:报销创建/编辑/提交、invoice pool bind/unbind、OCR 自动匹配、差旅补助、材料预览、付款准备相关聚焦套件。
|
||||
- 全局:server/client typecheck/build、Trellis governance、docs audit、`git diff --check`。
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "reimbursement-expense-policy-invoice-guard",
|
||||
"name": "reimbursement-expense-policy-invoice-guard",
|
||||
"title": "报销费用口径与票据明细门禁",
|
||||
"description": "统一企业报销费用类型与材料政策,并以默认关闭的双闸门逐步启用票据—明细强关联和申报金额门禁。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "expense type policy/schema/master data; reimbursement item invoice relation; staged submit guard",
|
||||
"package": "server,client,docs",
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-29",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "6e999d5",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"08-30-reimbursement-expense-policy-invoice-guard-deploy",
|
||||
"08-30-reimbursement-assets-prior-approval-correction",
|
||||
"08-30-reimbursement-employee-guide-b2-sync",
|
||||
"08-30-b2-submit-error-ocr-fallback"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"server/prisma/schema.prisma",
|
||||
"server/src/scripts/seed-config.js",
|
||||
"server/src/services/featureFlags/index.js",
|
||||
"server/src/config.js",
|
||||
"server/src/routes/reimbursements.js",
|
||||
"server/src/routes/invoicePool.js",
|
||||
"client/src/pages/ReimbursementDetailPage.tsx",
|
||||
"client/src/pages/MobileReimbursementDetailPage.tsx",
|
||||
"docs/产品口径.md",
|
||||
"docs/报销发票附件规则.md",
|
||||
"docs/需求-报销发票OCR规约.md"
|
||||
],
|
||||
"notes": "本地实施和只读专项复核已完成;B2 env/DB 双闸仍默认关闭,未部署、未应用生产 migration、未写生产配置。2,000 元/条明细的选类提示阈值已批准。会计科目映射由财务负责人在 B1 生产启用前提供,目标回填日期 2026-09-05;未取得正式科目表时保持 null,不由开发人员猜测。生产只读审计确认 0014/0016/0017/0018 均需退回修正;顺序改为先部署 B1、隔离验证并启用 B2,再由真实财务统一退回。B2 不校验日期粒度,0014 必须按 5 个发票日期拆分并由财务人工复核。部署合同见子任务 08-30-reimbursement-expense-policy-invoice-guard-deploy。B3 财务一页式及行内费用标准提示恢复仍为后续任务。",
|
||||
"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,58 @@
|
||||
# 设计:取消报销资产事前批准强制
|
||||
|
||||
## 设计边界
|
||||
|
||||
本轮是对 B1 产品口径的最小纠偏,不新增 schema、feature flag 或审批机制。生产继续运行 `0f9f403`,新候选只改变 assets 明细的保存/提交条件、前端呈现、seed description 与事实源。
|
||||
|
||||
## 服务端兼容合同
|
||||
|
||||
`asset_prior_approval_basis` 保持 nullable,API 的 `assetPriorApprovalBasis?: string | null` 保持兼容:
|
||||
|
||||
| 场景 | 目标行为 |
|
||||
| --- | --- |
|
||||
| 新建 assets,字段缺失/空白 | 保存 null,允许创建 |
|
||||
| 编辑 assets,字段 omitted、现值为 null | 保持 null,允许编辑,不发生 null `.trim()` |
|
||||
| 编辑 assets,字段 omitted、现值有值 | 保留现有值,避免隐藏 UI 后静默清空 |
|
||||
| 编辑 assets,字段显式 null/空白 | 清空为 null |
|
||||
| assets 切换为非 assets | 清空为 null |
|
||||
| 提交 assets 且字段为空 | 不阻断,继续执行金额、票据和 workflow 门禁 |
|
||||
|
||||
创建/编辑仍查询费用类型以验证 enabled/category;category 只用于切换到非 assets 时清理兼容字段,不再决定必填。
|
||||
|
||||
删除创建/编辑必填分支时,原本被校验守卫保护的 `.trim()` 必须同时改为安全归一化:
|
||||
|
||||
```js
|
||||
body.assetPriorApprovalBasis?.trim() || null
|
||||
targetApprovalBasis?.trim() || null
|
||||
```
|
||||
|
||||
不允许用非空断言或默认虚构文本代替 null 兼容。
|
||||
|
||||
## 前端数据流
|
||||
|
||||
- PC `ReimbursementItemEditor` 的表单模型移除 `assetPriorApprovalBasis`,不渲染输入、不参与 disabled、不进入 `onSave`。
|
||||
- PC 详情保存 payload 不再写该字段,编辑初始值也不再从详情 DTO 映射。
|
||||
- 移动端 `ItemFormState`、新增/编辑初始化和 mutation payload 同步移除该字段。
|
||||
- PC 表格与移动卡片移除批准依据展示。API DTO 类型保留,避免对旧服务端/客户端契约做无收益破坏。
|
||||
|
||||
## 主数据说明
|
||||
|
||||
seed 继续通过 upsert 更新启用类型 description,不触碰 `account_code_default`。推荐文案:
|
||||
|
||||
- 设备采购:`通用设备采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。`
|
||||
- 家具采购:`办公家具采购;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。`
|
||||
- 办公电子设备:`单条明细金额达到 2,000 元的办公电子设备通常选择本类型;请在明细说明中写明物品名称、实际使用人或责任人、存放位置,便于资产登记。该门槛不等同于会计固定资产确认标准。`
|
||||
|
||||
## 发布与回滚
|
||||
|
||||
- 无 migration;init 仍可运行既有 migration status + seed 流程,必须确认 pending=none、seed 退出码 0。
|
||||
- 部署后核对三个 description、B2 仍为 global on、`/ready` 和 bundle marker 为新候选。
|
||||
- 公网用合成 assets 草稿验证 PC/移动保存和提交,不使用 0017 做 smoke。
|
||||
- 异常时回滚应用到本轮发布前候选 `0f9f403`;数据库兼容列和已存在值不需要补偿。回滚后旧候选会恢复强制,因此 0017 在纠偏验收完成前始终不得退回。
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 最大兼容风险是隐藏输入后编辑请求携带 null,误清历史值;通过前端 payload 断言和服务端 omitted-field 测试锁定。
|
||||
- 最大运行时风险是删除必填校验后暴露下游 `.trim()` 的 null/undefined 假设;创建 omitted、编辑 null omitted 和编辑有值 omitted 三条路由测试必须同时覆盖。
|
||||
- 最大文案风险是四处 2,000 元提示只改一半;E2E 同时覆盖 PC/移动编辑态和展示态。
|
||||
- seed 失败会造成代码已取消强制但费用类型仍宣称要批准;部署验收必须同时核应用行为和三条主数据文案。
|
||||
@@ -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,46 @@
|
||||
# 实施清单:取消报销资产事前批准强制
|
||||
|
||||
## 1. 规划与候选边界
|
||||
|
||||
- [x] PRD、design、implement 经用户审查并明确批准实施。
|
||||
- [x] 确认当前生产仍运行 `0f9f403`,0017 尚未退回;0016 可由真实财务并行推进,不把真人单据用于本轮 smoke。
|
||||
|
||||
## 2. 服务端纠偏
|
||||
|
||||
- [x] 删除明细创建、编辑和提交三处 assets 批准依据强制错误。
|
||||
- [x] 同步修复被原校验遮蔽的两处空值解引用:create 使用 `body.assetPriorApprovalBasis?.trim() || null`,patch 使用 `targetApprovalBasis?.trim() || null`。
|
||||
- [x] 规范创建/编辑字段语义:create omitted/空白落 null;patch 现值为 null 且 omitted 保持 null、有值且 omitted 保留、显式空清除、切换非 assets 清除。
|
||||
- [x] 保留 Prisma 列、请求 schema、详情响应和 API 兼容字段,不新增 migration。
|
||||
|
||||
## 3. PC 与移动端纠偏
|
||||
|
||||
- [x] PC editor 移除输入、状态、初始值、保存 payload 和 disabled 条件;详情表格移除展示。
|
||||
- [x] 移动端移除 ItemFormState 字段、新建/编辑映射、保存 payload、输入、disabled 条件和卡片展示。
|
||||
- [x] 四处 2,000 元提示统一删除“并填写事前批准依据”,保留原触发函数和提示级语义。
|
||||
|
||||
## 4. 主数据、文档与规约
|
||||
|
||||
- [x] 更新三个启用 assets seed description 为资产登记导向,确认 update/create 分支均落地且不写 account code。
|
||||
- [x] 更新 `docs/产品口径.md` 和 reimbursement spec,删除不存在的强制审批制度与错误矩阵。
|
||||
- [x] 在 reimbursement spec 记录:取消必填校验时必须检查下游依赖的非空/类型/状态假设,并覆盖真实空值路径,不能只删除前置校验。
|
||||
- [x] 不修改既有 migration;migration 单测继续验证 nullable 与无破坏性操作。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- [x] 后端路由测试覆盖 assets 无依据可提交,以及创建 omitted→null、patch null+omitted→null、patch 有值+omitted→保留、显式清空、切换非 assets 清空;全部不得出现 500,B2 门禁不回退。
|
||||
- [x] seed 测试断言三个 description 不含批准措辞且包含资产登记信息。
|
||||
- [x] r111 E2E 覆盖 PC/移动无输入、无展示、可保存和四处新提示;明确关联与免票合同继续通过。
|
||||
- [x] 运行 `cd server && npm run prisma:validate` 和聚焦 Vitest。
|
||||
- [x] 运行 `cd client && npm run typecheck && npm run build` 及聚焦 Playwright。
|
||||
- [x] 运行 Trellis governance、process contract、docs registry、敏感信息检查和 `git diff --check`。
|
||||
|
||||
## 6. 复核、提交与部署门禁
|
||||
|
||||
- [x] 实施后执行只读专项 review,重点核权限/提交门禁未误删、API 兼容和 seed 文案一致性。
|
||||
- [ ] 用户审查通过后,以中文 commit 引用 task 名,push 与部署分别取得授权。
|
||||
- [ ] 无 migration 部署仍显式记录 pre/post pending=none、init/seed 退出码 0、三条 description、B2 global on、`/ready` 和 bundle marker。
|
||||
- [ ] 公网合成 assets 草稿 PC/移动验证通过并清理后,才由真实财务按已批准理由退回 0017。
|
||||
|
||||
## 7. 回滚
|
||||
|
||||
- [ ] 应用或 seed 验收失败时停止 0017,回滚应用到 `0f9f403`;不回滚数据库列、不清历史值、不改 B2 关联数据。
|
||||
@@ -0,0 +1,81 @@
|
||||
# 取消报销资产事前批准强制
|
||||
|
||||
## Goal
|
||||
|
||||
纠正 B1 把不存在的公司制度做成系统强制的问题:公司当前不要求办公家具、通用设备或办公电子设备采购事前审批,报销系统不得要求员工填写“事前批准依据”才能保存或提交。保留已上线的兼容字段与历史数据,不做 migration;把 assets 类型的员工说明改为资产登记导向,要求在明细说明中写清物品、使用人或责任人、存放位置。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- 当前生产候选 `0f9f403` 已上线 B1/B2。B1 在 PC、移动端和服务端把所有 `category=assets` 明细一律要求填写事前批准依据,但公司没有对应的事前审批制度。
|
||||
- 继续保留必填或教员工填写固定话术都会制造虚假合规痕迹;改成大额门槛仍然是在实现一条不存在的规则,因此本轮直接取消强制,不设置金额触发条件。
|
||||
- `ReimbursementItem.asset_prior_approval_basis` 已是可空列;生产现有明细没有已填写值,但仍按兼容原则保留列、API 字段和任何历史值,不删除、不回填、不迁移。
|
||||
- `BX-20260828-0016` 是外部技术服务费用,不含 assets 明细,可在本任务并行规划/实施期间由真实财务作为真人 canary 退回。`BX-20260828-0017` 的实物是办公家具,必须等待本纠偏上线后再退回。
|
||||
|
||||
## Scope
|
||||
|
||||
- 报销 assets 明细创建、编辑、提交的批准依据校验。
|
||||
- PC/移动端 assets 明细编辑、保存和展示,以及办公用品 2,000 元选类提示。
|
||||
- 三个启用 assets 费用类型的 seed description。
|
||||
- 产品口径、报销规约、聚焦后端与端到端测试,以及无 migration 的受控部署验收。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 取消所有 assets 批准依据阻断
|
||||
|
||||
- 服务端删除三个强制点:明细创建 `server/src/routes/reimbursements.js:3670`、明细编辑 `:3835`、提交时 `missingAssetApprovalItemIds` 检查 `:4061-4077`。
|
||||
- assets 明细未提供 `assetPriorApprovalBasis` 时允许创建、编辑和提交;票据—明细关联、票面金额、正金额、费用类型启用状态及其他既有门禁保持不变。
|
||||
- 创建 assets 明细时,批准依据缺失或空白保存为 null。编辑时字段未传必须保留历史值,显式传 null/空白才清空;切换为非 assets 类型继续清空该字段。不得因页面隐藏字段而在编辑日期、金额、类型或说明时静默丢失历史值。
|
||||
- 删除必填校验后必须同步消除其下游的非空假设:创建路径 `server/src/routes/reimbursements.js:3686-3687` 使用 `body.assetPriorApprovalBasis?.trim() || null`;编辑路径 `:3854-3856` 使用 `targetApprovalBasis?.trim() || null`。字段 omitted、数据库现值为 null 或显式 null 均不得触发 `.trim()` TypeError/500。
|
||||
|
||||
### R2 移除无用途的前端输入与展示
|
||||
|
||||
- PC `ReimbursementItemEditor.tsx` 和移动端 `MobileReimbursementDetailPage.tsx` 不再渲染“事前批准依据”输入框,也不再因其为空禁用保存按钮。
|
||||
- PC `ReimbursementDetailPage.tsx` 和移动端明细卡不再展示“事前批准依据”行。API 类型与详情响应字段保留以兼容旧客户端和历史数据,但当前 UI 不再发送该字段。
|
||||
- 清理 PC/移动端编辑状态、初始值和保存 payload 中只为该输入框服务的数据流,确保普通编辑不显式发送 null 清空历史值。
|
||||
|
||||
### R3 把资产类型说明改为资产登记导向
|
||||
|
||||
- `assets_equipment`、`assets_furniture`、`assets_office_electronics` 三个启用类型不再出现“事前批准”“批准人”“批准时间”或“批准依据”。
|
||||
- 类型说明要求员工在明细“说明”中写清物品名称、实际使用人或责任人、存放位置,供管理员后续人工建卡和资产登记使用。
|
||||
- `assets_office_electronics` 保留单条明细 `2,000 元`选类提示以及“不等同于会计固定资产确认标准”的声明。
|
||||
|
||||
### R4 统一 2,000 元提示文案
|
||||
|
||||
- 以下四处提示统一为“该金额通常应选择办公电子设备。”,删除“并填写事前批准依据”:PC 编辑态 `ReimbursementItemEditor.tsx:188`、PC 展示态 `ReimbursementDetailPage.tsx:1282`、移动编辑态 `MobileReimbursementDetailPage.tsx:1112`、移动展示态 `MobileReimbursementDetailPage.tsx:1143`。
|
||||
- 提示仍只在 `office_supplies` 且单条明细金额大于等于 `2,000 元`时显示,保持提示级、不阻断、不自动改类。
|
||||
|
||||
### R5 文档、规约与测试同步
|
||||
|
||||
- 更新 `docs/产品口径.md:194`,明确公司当前无 assets 事前审批要求,资产信息通过明细说明支持人工登记。
|
||||
- 更新 `.trellis/spec/hyb-oa/reimbursement-invoice-ocr.md:984/988/1031/1032`,删除强制批准合同和对应错误矩阵,保留 nullable/API 兼容与非 assets 清理语义。
|
||||
- 反转 `server/tests/unit/routes/reimbursements.expense-standards.test.js:337-348` 的断言:缺批准依据的 assets 明细不得再返回 400,并补创建/编辑兼容测试。至少覆盖:创建 assets 且字段 omitted 返回成功并落库 null;编辑数据库现值为 null 的 assets 且字段 omitted 返回成功并保持 null;编辑有历史值的 assets 且字段 omitted 保留原值;显式 null/空白清空;切换非 assets 清空。
|
||||
- `server/tests/unit/scripts/reimbursement-expense-policy-migration.test.js:17` 继续证明列为 nullable 且 migration 无破坏性操作;本轮不得新建或修改生产 migration。
|
||||
- 更新 `client/tests/e2e/r111-invoice-reimbursement-guard.spec.ts:747-804`:PC/移动端不显示批准输入或展示,保存不被阻断,四处 2,000 元提示使用新文案;票据明确关联和免票补助合同继续通过。
|
||||
- 在 reimbursement spec 沉淀通用防回归规则:取消或放宽必填校验时,必须审计后续代码是否依赖该校验提供的非空、类型或状态保证,并为真实空值路径补回归测试;不得只删除前置错误分支。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] PC 和移动端选择三个 assets 类型时均不出现“事前批准依据”输入框,未填写该字段也能保存明细并提交。
|
||||
- [x] 服务端创建、编辑、提交均不再返回 `required_for_assets` 或 `asset_prior_approval_basis_required`,其他 B2 票据门禁不回退。
|
||||
- [x] 创建 assets 字段 omitted、编辑 assets 现值为 null 且字段 omitted 均成功并保持 null,不出现 TypeError/500;有值 omitted、显式清空和切换非 assets 的兼容语义均有测试锁定。
|
||||
- [x] 当前 UI 编辑 assets 明细不会发送该兼容字段;服务端遗漏字段保留历史值,显式清空有效,切换非 assets 仍清空。
|
||||
- [x] PC/移动编辑态和展示态四处 2,000 元提示一致,仍为提示级且不自动改类型。
|
||||
- [x] 三个启用 assets 类型 description 均转为资产登记导向;办公电子设备仍保留 2,000 元及非会计固定资产门槛声明。
|
||||
- [x] 数据库列、Prisma schema、API 字段及历史数据保留,无 migration、无批量更新、无数据清理。
|
||||
- [x] 聚焦后端测试、前端 typecheck/build、r111 E2E、Trellis/process/docs 治理和 `git diff --check` 全部通过。
|
||||
- [ ] 新候选完成独立只读复核、push、无 migration 部署和公网 PC/移动验证后,才允许真实财务退回 0017。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 新增采购申请、审批流、金额批准门槛、数量/单价模型或自动资产建卡。
|
||||
- 改造票据直接重关联、明细删除联动、B3 财务一页式或恢复行内费用标准提示。
|
||||
- 修复 `deploy-existing.sh --no-deps`、PGDATA 容器内检查、admin smoke 治理等运维债。
|
||||
- 清空 `asset_prior_approval_basis` 历史值、删除数据库列或修改既有 migration。
|
||||
- 代替真实财务退回、审批或付款;0016 canary 和 0017 退回继续按生产写入门禁单独执行。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 服务端:Prisma validate;聚焦 reimbursement expense-standards、创建/编辑路由、seed defaults 与既有 migration 测试,确认无批准依据可通过且 B2 金额/关联门禁不回退。
|
||||
- 前端:typecheck、build、r111 聚焦 Playwright;同时覆盖 PC/移动编辑态和展示态的字段消失、保存可用与新提示文案。
|
||||
- 数据与部署:确认无新增 migration、pre/post pending=none、init/seed exit 0、三个 description 正确、B2 仍 global on、合成 assets 草稿通过且 0017 未被当作 smoke。
|
||||
- 治理:task validate、Trellis governance、process contract、docs registry、敏感信息扫描和 `git diff --check`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-assets-prior-approval-correction",
|
||||
"name": "reimbursement-assets-prior-approval-correction",
|
||||
"title": "取消报销资产事前批准强制",
|
||||
"description": "纠正不存在的 assets 事前审批制度:取消报销明细强制批准依据,隐藏无用途 UI,保留兼容字段与历史数据,并把资产类型说明改为登记导向。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "assets reimbursement validation; PC/mobile item editing; expense-type seed descriptions; reimbursement product/spec contracts",
|
||||
"package": null,
|
||||
"priority": "P0",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-30",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "36c86f1",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
|
||||
"relatedFiles": [],
|
||||
"notes": "生产 0f9f403 已上线 B1/B2;0016 非 assets 可并行作为真人 canary,0017 为家具采购,必须等待本纠偏上线后再退回。无 migration、无 flag 或生产账号写入。",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# 设计:报销费用政策与票据门禁生产部署
|
||||
|
||||
## 发布边界
|
||||
|
||||
本任务不再修改业务代码。候选包含父任务业务提交 `6e999d5` 和本部署合同;规划文档提交完成后以最终干净 `HEAD` 冻结,并在外部审批记录写完整 SHA 与 12 位 bundle marker。当前生产 `59dc5f6` 是固定应用回滚基线。
|
||||
|
||||
生产切换分为三个独立状态:
|
||||
|
||||
```text
|
||||
B1 部署:新 schema + 主数据 + 表单合同
|
||||
env hard gate = off(或 env on 但 DB force_off)
|
||||
DB flag = force_off
|
||||
B2 effective = false
|
||||
|
||||
B2 隔离灰度:同一应用候选
|
||||
env hard gate = on
|
||||
DB flag = force_on + department_allowlist + 隔离空部门
|
||||
B2 只对 smoke employee 生效
|
||||
|
||||
B2 全局启用:同一应用候选
|
||||
env hard gate = on
|
||||
DB flag = force_on + global
|
||||
B2 对全局提交生效
|
||||
```
|
||||
|
||||
任何状态跃迁都必须先完成上一状态的验收,并取得对应生产授权。
|
||||
|
||||
## 验收身份与数据隔离
|
||||
|
||||
- admin smoke:只用于系统设置、非参与人视图和 flag 操作,不代行财务审批。
|
||||
- employee smoke:从当前 5 人部门迁入已确认零成员的隔离部门,建立合成草稿并验证 owner 合同;终态常驻该部门,不再迁回。
|
||||
- manager smoke:新建 manager + employee 的 OA-only 专用身份并设为 employee smoke 的直属 manager。普通 employee 没有直属 manager 时,提交会被 `no_manager_found` 阻断,不会走 finance fallback。
|
||||
- finance smoke:新建 finance + employee 的 OA-only 专用身份,放入隔离部门,用于财务只读投影;不恢复已消失旧账号,不绑定真人身份。因为完整 finance 角色包含付款能力,账号在验收窗口外保持 disabled,每次启停均需生产账号写入授权。
|
||||
- 真实 finance:仅负责 4 张真人单据的业务退回和重提复核,凭据不交给自动化。
|
||||
|
||||
隔离部门改名为“系统测试专用(勿分配员工)”。名称用于降低 HR 误分配概率,但每次灰度仍必须以实时成员集合为准:只允许 employee/manager/finance 三个 smoke,任何第四个成员都使灰度门关闭。
|
||||
|
||||
合成材料必须是无真实抬头、号码、账户和业务正文的测试 PDF/图片。成功提交的合成单据通过 owner 撤回回到草稿再删除;阻断场景直接删除草稿。审计记录保留。
|
||||
|
||||
## Migration 与 seed
|
||||
|
||||
唯一 migration:`20260829120000_reimbursement_expense_policy_invoice_guard`。
|
||||
|
||||
- migration 新增向前兼容列并回填两项免票类型。
|
||||
- `seed:config` 落地费用类型改名、停用、新建、description、requires_invoice 和 B2 DB `force_off` 初值。
|
||||
- seed 更新路径不写 `account_code_default`,部署前后须按 code 对比,防止既有财务映射丢失。
|
||||
- deploy init 同时运行 `seed:trialrun`,因此 pre/post 证据必须覆盖 migration 与主数据,而不能只看 `_prisma_migrations`。
|
||||
|
||||
## B2 配置数据流
|
||||
|
||||
生产环境不由 `SYNC_ENV=0` 自动同步。正确顺序为:
|
||||
|
||||
1. B1 init 后确认 DB flag 记录已由 seed 显式创建为 `force_off`,不得接受 `missing_flag_record`,并保存完整快照。
|
||||
2. 经生产配置授权把 env hard gate 设为 true,重启同一候选。
|
||||
3. 验证 `envEnabled=true`、`effectiveEnabled=false`、reason=`flag_force_off`。
|
||||
4. 经灰度授权前实时确认隔离部门只有三个 smoke,并在获批窗口临时启用 finance smoke;随后把 DB 更新为 department allowlist,仅含隔离部门。
|
||||
5. 执行合成验证;失败恢复调用前快照,优先 `force_off`。
|
||||
6. 灰度 PASS 后,经全局授权更新为 global force_on;验收窗口结束立即 disabled finance smoke。
|
||||
|
||||
flag 更新使用 API 的 expectedVersion 并发控制并自动写审计。关闭 flag 只改变后续行为,不删除已建立的关联。
|
||||
|
||||
门禁状态证据使用精确 reason:`env_hard_off` → `flag_force_off` → 名单外 `department_not_allowed` / 隔离部门 `flag_force_on_department_allowed` → 全局 `flag_force_on_global`。普通 flag 列表无法按 actor 完成 strict department 判断,须在受控容器内调用同一 evaluator 做只读诊断,并与详情 DTO 的 `invoiceGuardEnabled` 交叉核对。
|
||||
|
||||
## 真实单据处置
|
||||
|
||||
B2 全局启用后再退回 4 张真人单据,使员工只重做一次并获得系统关联/金额提示。日期粒度仍由退回说明与财务人工复核承担:
|
||||
|
||||
| 单号 | 系统可拦 | 人工要求 |
|
||||
| --- | --- | --- |
|
||||
| 0014 | 否 | 5 个日期拆 5 条明细,重提后人工确认 |
|
||||
| 0016 | 未关联票据 | 至少按 3 个日期拆分并逐票关联 |
|
||||
| 0017 | 未关联、超票面 | 2 个日期重做、正式 PDF、差额说明 |
|
||||
| 0018 | 未关联票据 | 修正日期;同日同类可保留一条 |
|
||||
|
||||
已付款 0008 与空草稿 0013 不进入处置动作。
|
||||
|
||||
## 回滚与补偿
|
||||
|
||||
回滚分三层,不能混用:
|
||||
|
||||
1. B2 行为异常:DB flag 立即 `force_off`,关联数据保留。
|
||||
2. 应用异常:确保 B2 off,使用 existing 入口回滚到 `59dc5f6`;不逆 migration。
|
||||
3. B1 主数据错误:应用回滚不会恢复 seed 结果。只在单独批准后,依据部署前快照对受影响 code 做精确补偿;不执行整库 restore,不删除新增类型或历史外键。
|
||||
|
||||
回滚后重复 build、migration status、runtime/external smoke、挂载、备份合同与公网入口检查。
|
||||
|
||||
## 证据与隐私
|
||||
|
||||
报告只保存 PASS/FAIL、SHA、migration 名、类型 code/状态、flag 状态、合成标识和计数。不得保存密码、token、完整账号、真实票据、姓名、业务正文或原始 API payload。材料预览只记录页数、页序和比例,允许保留系统生成的审计记录。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 实施清单:报销费用政策与票据门禁生产部署
|
||||
|
||||
## 1. 规划与冻结
|
||||
|
||||
- [ ] PRD、design、implement 经用户审查并明确批准进入实施。
|
||||
- [ ] 本规划提交完成后确认工作区完全干净,以最终 `HEAD` 冻结候选;外部审批记录写死完整 SHA 与 12 位 bundle marker。
|
||||
- [ ] 冻结后无任何提交、修改或 untracked 文件;发生漂移则重新冻结、重新审查。
|
||||
|
||||
## 2. 验收准备(独立生产写入授权)
|
||||
|
||||
- [ ] 再次只读确认隔离部门零真人成员,经生产写入批准改名为“系统测试专用(勿分配员工)”,并固定为常驻 smoke 部门。
|
||||
- [ ] 新建 OA-only manager smoke(manager + employee)与 finance smoke(finance + employee),凭据写入私有 smoke env,不输出、不绑定真人。
|
||||
- [ ] 把 employee smoke 迁入常驻 smoke 部门,将 manager smoke 设置为其直属 manager;先用合成空壳草稿确认 workflow 可分配,再创建明显标识的合成历史草稿和非真实票据/附件。
|
||||
- [ ] finance smoke 创建后默认 disabled;验收窗口开始前经单独批准临时启用,窗口结束立即 disabled。不得让持付款能力的 smoke finance 常驻 active。
|
||||
- [ ] 明确授权材料合并预览产生设计内审计记录;真实材料不落测试报告。
|
||||
|
||||
## 3. Push 与来源门禁
|
||||
|
||||
- [ ] 用户单独批准 push;只推送冻结候选所在 `main`。
|
||||
- [ ] 确认 `origin/main` 等于或包含候选,严格 provenance PASS,`published=true`、`remoteVerified=true`、`overrideUsed=false`。
|
||||
- [ ] push/provenance 不推定部署、配置或业务写入授权。
|
||||
|
||||
## 4. B1 部署前门禁
|
||||
|
||||
- [ ] 用户在 provenance 通过后单独批准 B1 部署。
|
||||
- [ ] `check-existing-deployment.sh`、生产 identity、existing 数据/附件挂载和备份合同均 PASS。
|
||||
- [ ] init 前显式记录 Prisma status,pending 只能是 `20260829120000_reimbursement_expense_policy_invoice_guard`。
|
||||
- [ ] 保存受影响费用类型 code/name/enabled/description/category/account_code 的脱敏快照;记录 B2 env/DB flag 调用前状态。
|
||||
- [ ] 部署前记录 B2 flag 是否尚不存在;不得把缺失解释为最终关闭合同。若记录已存在则必须为 `force_off`;若 env hard gate 已提前获批设为 true,必须证明 effective 仍为 false。
|
||||
|
||||
## 5. B1 正式发布
|
||||
|
||||
- [ ] 使用 `deploy-existing.sh`、existing 模式、`SYNC_ENV=0` 执行 migration、seed 和应用切换。
|
||||
- [ ] init 后显式记录 migration status;核对新增列、两项免票、其余启用类型需票、费用类型改名/停用/新建和 account code 保留。
|
||||
- [ ] 核对 B2 DB flag 记录明确存在、override=`force_off`、reason 不为 `missing_flag_record`;缺失立即阻断,不继续 B1 验收。
|
||||
- [ ] `/ready`、12 位 bundle marker、runtime/external smoke、服务状态和备份合同 refresh 全部 PASS。
|
||||
- [ ] B2 effective off;owner 普通维护、assets 批准依据、2,000 元提示、无票补助和非参与人只读投影 PASS。
|
||||
- [ ] admin/finance/employee 角色化公网验收 PASS;材料合并预览页序/比例正常并只产生获批审计。
|
||||
|
||||
## 6. B2 env hard gate(独立生产配置授权)
|
||||
|
||||
- [ ] 保存 env/flag 快照,确认 DB `force_off`。
|
||||
- [ ] 将生产 `REIMBURSEMENT_INVOICE_GUARD_ENABLED=true` 写入受控 env,重启同一候选,不同步其他 env。
|
||||
- [ ] 验证 env on、DB force_off、effective off 且 reason=`flag_force_off`;失败则恢复 env 快照并重启。
|
||||
|
||||
## 7. B2 隔离灰度(独立 flag 授权)
|
||||
|
||||
- [ ] 灰度前实时读取隔离部门成员,确认恰好只有 employee/manager/finance 三个 smoke、无真人;临时启用 finance smoke 后,用 expectedVersion 把 flag 更新为 `force_on + department_allowlist + 隔离部门`。受控容器内只读诊断确认隔离部门 reason=`flag_force_on_department_allowed`、名单外 reason=`department_not_allowed`,并与详情 DTO `invoiceGuardEnabled` 交叉核对。
|
||||
- [ ] 合成验证 owner 普通提交成功。
|
||||
- [ ] 合成验证两项无票补助不被阻断。
|
||||
- [ ] 合成验证多票显式关联成功。
|
||||
- [ ] 合成验证申报金额高于票面时准确阻断。
|
||||
- [ ] 合成验证旧草稿补齐关联/批准依据后可提交。
|
||||
- [ ] 合成验证 admin/finance 非参与人无法写入且零业务副作用。
|
||||
- [ ] 切回 `force_off` 验证旧提交路径恢复、既有关联不删除不解绑,再恢复灰度快照继续最终判断。
|
||||
- [ ] 任一失败立即恢复调用前 flag 快照,至少先落 `force_off`;不操作 4 张真人单据。
|
||||
|
||||
## 8. B2 全局启用(独立授权)
|
||||
|
||||
- [ ] 灰度七项全 PASS,成功合成单据已撤回并删除,失败草稿已删除;审计历史保留。
|
||||
- [ ] 用户单独批准 global enable;用 expectedVersion 更新为 global force_on。
|
||||
- [ ] 验证全局 effective on、reason=`flag_force_on_global`、owner 基线、非参与人只读、错误提示、runtime 指标和审计记录。
|
||||
- [ ] employee/manager/finance smoke 常驻隔离部门,确认成员集合仍只有三个 smoke且无真人;不再执行迁回写入,finance smoke 在窗口结束后立即 disabled。
|
||||
|
||||
## 9. 四张真实单据退回与重提
|
||||
|
||||
- [ ] 由真实 finance 退回 0014:说明“5 个发票日期拆成 5 条明细”,重提后人工确认。
|
||||
- [ ] 由真实 finance 退回 0016:至少按 3 个日期拆分并逐票关联。
|
||||
- [ ] 由真实 finance 退回 0017:按 2 个日期重做、从发票入口上传正式 PDF、逐票关联并解释 1,949.81 元差额。
|
||||
- [ ] 由真实 finance 退回 0018:修正日期并逐票关联;同日同类可保留一条。
|
||||
- [ ] 0008 已付款只留历史关联缺口;0013 空草稿不改。
|
||||
|
||||
## 10. 失败与回滚
|
||||
|
||||
- [ ] 切换前失败只停止;切换后失败先 B2 `force_off`,再用同一 existing 入口回滚固定应用基线 `59dc5f6`。
|
||||
- [ ] 不 reset、不逆 migration、不删除关联;主数据问题只凭部署前快照和单独批准做精确补偿。
|
||||
- [ ] 回滚后复核 build、migration status、runtime/external smoke、DB/附件挂载、备份合同与公网入口。
|
||||
|
||||
## 11. 收口
|
||||
|
||||
- [ ] 所有生产证据脱敏回填;不保存凭据、真人信息、真实票据或原始 payload。
|
||||
- [ ] B1/B2 与四张单据处置全部完成后才通知普通用户测试、归档本部署子任务并更新父 task。
|
||||
- [ ] B3 一页式继续作为独立后续任务;日期粒度自动门禁仍明确不在本轮。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 报销费用政策与票据门禁生产部署
|
||||
|
||||
## Goal
|
||||
|
||||
把已完成并复核的报销费用政策与票据门禁安全发布到生产:先以 B2 有效关闭的状态完成 B1 migration、主数据和 owner 基线验收,再在隔离部门内验证 B2,最后全局启用并让财务统一退回 4 张真实审批中单据。全过程不得拿真人单据做灰度试验,不得因应用回滚误删 migration 或已建立的票据—明细关系。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- 生产当前运行候选为 `59dc5f6`;本地 `main` 比 `origin/main` 领先 4 个提交,B1/B2 业务实现为 `6e999d5`,尚未推送、部署或应用生产 migration。
|
||||
- 待应用 migration 固定为 `20260829120000_reimbursement_expense_policy_invoice_guard`,只新增 `expense_types.requires_invoice`(非空、默认 true)、`reimbursement_items.asset_prior_approval_basis`(可空),并把两项补助精确回填为免票。
|
||||
- init 会运行 migration、`seed:config` 与 `seed:trialrun`;费用类型改名、停用和新建由 seed 落地,不能只核 migration 表。
|
||||
- B2 是 env hard gate、代码白名单和 DB flag 三件套。生产部署使用 `SYNC_ENV=0`,不会自动把新增 env 写入生产;env 未开启时 DB `force_on` 无效。
|
||||
- B2 提交门禁只检查:已绑定发票是否关联明细、需票明细是否有关联票面金额、申报金额是否高于关联票面合计。它不自动校验“同一费用类型 + 同一发生日期”的明细粒度。
|
||||
- 当前 4 张审批中单据均需退回重做:`0014` 票据关联/金额完整但 5 个日期被压成 1 条明细,B2 无法拦截;`0016`、`0017`、`0018` 可被关联或金额门禁拦住,但日期/粒度仍需人工说明和复核。
|
||||
- 生产现有 admin/employee smoke 可登录,旧 finance smoke 身份已不在 OA 用户表;employee smoke 当前没有自有报销。现有 3 个部门中有 1 个无成员,可在明确授权后作为隔离灰度范围;employee smoke 当前所在部门有 5 个用户,不能直接作为灰度范围。
|
||||
- 材料合并预览会创建短期进程内预览包并写预览审计记录;这是设计内审计写入,必须纳入验收授权,而不是伪装成零写入检查。
|
||||
|
||||
## Scope
|
||||
|
||||
- 创建生产验收条件:把现有空部门固定为常驻 smoke 部门,新建专用 OA-only manager/finance smoke 身份,把 employee smoke 迁入并长期保留,建立专用合成草稿和合成票据;凭据只通过私有 env 注入。
|
||||
- 冻结、推送并严格验证包含 B1/B2 实现与本部署合同的最终候选。
|
||||
- 使用 existing 部署入口完成 migration、seed、应用切换、构建标识、runtime/external smoke、备份合同刷新和角色化公网验收。
|
||||
- B1 验收通过后,在 DB 继续 `force_off` 时显式启用生产 env hard gate 并重启同一候选;随后仅对隔离部门开启 B2,验证通过后再全局开启。
|
||||
- B2 全局开启后,由真实财务角色统一退回 `BX-20260828-0014/0016/0017/0018`;员工按新规则修正并重提,财务人工复核日期粒度。
|
||||
- 已付款 `BX-20260730-0008` 仅留历史关联缺口记录,不退回、不改付款数据;空草稿 `BX-20260805-0013` 不处理。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 授权与候选冻结
|
||||
|
||||
- 规划批准、生产验收准备写入批准、push 批准、生产配置批准、部署批准、B2 灰度批准、B2 全局启用批准和真实单据退回批准互不推定。
|
||||
- 本次规划文档提交完成后,以最终干净 `HEAD` 冻结候选;具体完整 SHA 和 12 位 bundle marker 记在外部审批记录,不写回仓库。冻结后任何提交、tracked/untracked 变化或 HEAD 漂移都使审批失效。
|
||||
- push 后必须确认候选是 upstream 祖先,严格 provenance 为 PASS,`published=true`、`remoteVerified=true`、`overrideUsed=false`;禁止 unpublished override。
|
||||
- 固定应用回滚基线为当前生产候选 `59dc5f6`。不得临场选择其他提交。
|
||||
|
||||
### R2 验收身份与合成数据
|
||||
|
||||
- 不恢复已从用户表消失的旧 manager/finance smoke;新建角色纯净的 OA-only manager smoke(manager + employee)和 finance smoke(finance + employee),不绑定或重置真实员工身份。
|
||||
- 把已确认无成员的部门改名为“系统测试专用(勿分配员工)”并固定为常驻 smoke 部门;employee smoke 迁入后不再迁回,manager smoke 设置为其直属 manager,三类 smoke 身份留在该部门供后续隔离灰度复用。迁移前以及每次部门灰度开启前,都必须确认成员集合恰好只有这三个 smoke;部门名称只是防误用提示,不能替代成员集合校验。
|
||||
- finance smoke 持有完整 finance + employee 角色,包含付款和全量财务职责,因此只允许在单独获批的验收窗口内启用。窗口结束后立即置为 disabled;后续再次启用、使用和停用都属于生产账号写入,不得从既往验收授权推定。
|
||||
- 普通 employee 缺直属 manager 时,报销提交会因 `blockOnUnresolvedAssignee=true` 和 `no_manager_found` 返回 409;`owner_self_fallback_role=finance` 只处理 owner 本人持有 CEO 角色的特殊情形,不得把 finance fallback 当作普通 smoke 的兜底方案。
|
||||
- 合成草稿、明细、票据和附件必须使用明显测试标识与非真实材料;不得复制真人票据、金额、说明或附件。
|
||||
- 所有成功提交的合成单据在验证后按合法 owner 流程撤回并删除;失败场景草稿直接删除。审计/工作流历史按系统合同保留,不做数据库硬删。
|
||||
- 材料预览允许产生 `REIMBURSEMENT_MATERIAL_PREVIEW_BUNDLE_ISSUED` / `REIMBURSEMENT_MATERIAL_PREVIEWED` 审计记录;证据只保留状态、页数、页序、比例与构建标识。
|
||||
|
||||
### R3 B1 部署
|
||||
|
||||
- 部署前必须显式记录 migration status,期望 pending migration 只能包含 `20260829120000_reimbursement_expense_policy_invoice_guard`;多、少或漂移均停止。
|
||||
- 使用 `deploy/trialrun/deploy-existing.sh`、existing 数据模式和 `SYNC_ENV=0`。不 reset 数据库,不覆盖环境、DB 或附件目录。
|
||||
- init 后再次显式记录 migration status,并核对:新增列存在、两项补助 `requires_invoice=false`、其他启用类型为 true、费用类型改名/停用/新建准确、`account_code_default` 未被 seed 清空。
|
||||
- 若正式科目表仍未提供,`account_code_default` 保持 null;责任角色为财务负责人,原目标回填日期 `2026-09-05` 保留在父 task,不由开发猜测编码,也不以此阻断本轮材料门禁上线。
|
||||
- B1 首次切换后 B2 DB 记录必须由 seed 显式创建且为 `force_off`,不得以 `missing_flag_record` 代替关闭意图;B2 必须保持 effective off。owner 普通草稿维护、assets 事前批准、2,000 元提示、无票补助和非参与人只读投影均须通过。
|
||||
|
||||
### R4 B2 hard gate 与隔离灰度
|
||||
|
||||
- B1 验收通过后,先确认 DB 为 `force_off`,再经单独生产配置批准把 `REIMBURSEMENT_INVOICE_GUARD_ENABLED=true` 写入受控生产 env,并重启同一候选。重启后应显示 env enabled 但 effective off。
|
||||
- 隔离灰度只能将 DB flag 设置为 `force_on + department_allowlist + 隔离部门 ID`;不得使用 employee smoke 原部门,不得把 4 张真人单据作为灰度样本。
|
||||
- 更新部门 allowlist 前必须重新读取部门成员与状态,确认只有 employee/manager/finance 三个 smoke、无真人成员,且 finance smoke 仅在当前获批窗口临时启用;任何额外成员或身份不确定都阻断灰度。
|
||||
- 每个状态必须记录精确 reason:env 未开为 `env_hard_off`;env on + DB off 为 `flag_force_off`;灰度名单外部门为 `department_not_allowed`;隔离部门在本轮 `force_on` 模式下为 `flag_force_on_department_allowed`;全局开启为 `flag_force_on_global`。不得只凭提交成功/失败推断门禁已运行。
|
||||
- 普通 feature flag 列表使用 `strictDepartment=false`,在部门 allowlist 状态只能给出延后判断,不能证明具体 actor 命中。灰度证据必须运行受控容器内只读 actor/department 诊断,并与报销详情 DTO 的 `invoiceGuardEnabled` 交叉验证;诊断不输出部门 ID、账号或原始 payload。
|
||||
- 隔离灰度必须验证:owner 普通提交成功;两项无票补助不被阻断;多票显式关联成功;超票面准确阻断;旧草稿补齐后可提交;非参与人零写入副作用;切回 `force_off` 后旧提交路径恢复且既有关联数据不删除、不解绑。
|
||||
- B2 不校验日期粒度的事实必须在验收结论和财务退回说明中保留;不得把 flag PASS 描述为“系统已自动保证明细粒度”。
|
||||
- 灰度失败立即把 DB 恢复为调用前完整快照,最迟先落 `force_off`;关闭 flag 只回退门禁,不回滚关联数据。
|
||||
|
||||
### R5 全局启用与真实单据处置
|
||||
|
||||
- 隔离灰度全部通过并清理合成单据后,必须再次获得全局启用批准,才可把 rollout 从部门 allowlist 改为 global;更新使用版本并发控制并产生 feature flag 审计。
|
||||
- 全局启用后先验证 effective global on、owner 基线、财务/管理员只读投影、错误提示和运行指标,再安排真实财务账号退回 4 张单据。
|
||||
- `0014` 退回说明必须写死“按 5 个发票日期拆成 5 条明细”,重提后由财务人工确认;B2 不会自动拦截原样重提。
|
||||
- `0016` 至少按 3 个发票日期拆分并逐票关联;`0017` 按 2 个发票日期重做、上传正式 PDF、逐票关联并解释 `1,949.81 元`差额;`0018` 若同日同类可保留一条,但须改正日期并关联全部 5 张票。
|
||||
- 退回必须由真实 finance 审批主体执行。admin、smoke admin 或脚本不得代行财务职责;Codex 不保存真实财务凭据。
|
||||
|
||||
### R6 失败与回滚
|
||||
|
||||
- 切换前失败只停止,不执行应用回滚。migration/seed 或应用切换开始后任一必需门禁失败,先确保 B2 DB `force_off`,再按同一 existing 入口回滚应用到 `59dc5f6`。
|
||||
- 应用回滚不逆向 migration、不删新增列、不删除票据—明细关联。新增列对旧应用为向前兼容。
|
||||
- init 已改变费用类型主数据,应用回滚不会自动恢复旧名称/启用状态。部署前必须保存受影响类型的脱敏主数据快照;只有 B1 主数据本身被判定错误且取得单独补偿批准时,才按快照执行精确补偿,不运行整库回滚或重放猜测 SQL。
|
||||
- 回滚后必须核对 `/ready`、bundle marker、migration status、runtime/external smoke、DB/附件挂载和备份合同;若 env hard gate 已写入,可保持 true,但 DB 必须 `force_off`。若回滚原因涉及 flag 配置或新代码误触发,则同时恢复 env hard-off 并重启旧应用。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 常驻部门已改名为“系统测试专用(勿分配员工)”;专用 manager/finance smoke、employee smoke 迁入和直属 manager 关系经单独生产写入批准,成员集合只有三个 smoke且不涉及任何真实员工身份或材料。
|
||||
- [ ] 最终候选冻结、单独 push、upstream 祖先关系和严格 provenance 全部通过。
|
||||
- [ ] 用户在 provenance 通过后另行批准 B1 生产部署;pre/post migration status 均明确记录,唯一 migration 正确应用。
|
||||
- [ ] B1 schema、费用主数据、requires_invoice、assets 批准依据、2,000 元提示和 account code 保留检查通过;B2 DB 记录明确存在且为 force_off,effective off reason 不是 missing record。
|
||||
- [ ] `/ready`、12 位 bundle marker、runtime/external smoke、备份合同刷新和角色化公网检查通过。
|
||||
- [ ] owner 合成草稿维护正常;finance/admin 非参与人无维护入口;材料合并预览成功并只产生获批的设计内审计。
|
||||
- [ ] env hard gate 在 DB `force_off` 状态下单独启用并重启,确认 env on / effective off 后才进入隔离灰度。
|
||||
- [ ] B2 隔离灰度七项合同全部通过;失败时 DB 快照恢复,既有关联数据未回滚。
|
||||
- [ ] 用户单独批准后 B2 全局 effective on;合成数据已按合法流程清理,employee/manager/finance smoke 常驻隔离部门且无真人混入,finance smoke 已在验收窗口结束后 disabled。
|
||||
- [ ] 真实财务按明确说明退回 0014/0016/0017/0018,重提后人工复核日期粒度;0008 与 0013 未被误改。
|
||||
- [ ] 任一失败按固定回滚/补偿边界处理,无 reset、无逆 migration、无批量伪造批准依据或票据关联。
|
||||
- [ ] 所有公网门禁完成前不通知普通用户测试、不归档父任务。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 修改 B1/B2 业务代码、把日期粒度新增为服务端阻断、补数量/单价模型或提前实施 B3 一页式。
|
||||
- 反向修改已付款 0008、替真人财务审批、保存真实凭据、复制真实票据作为测试材料。
|
||||
- 在本轮编造会计科目编码、重置数据库、回退 migration、批量写入事前批准依据或清空审计记录。
|
||||
- 把合成灰度批准推定为真实单据退回批准,或把部门灰度 PASS 推定为全局启用批准。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 来源:干净工作区、候选 SHA、独立 push、upstream 祖先关系、严格 provenance。
|
||||
- 生产 preflight:existing 部署合同、身份、数据挂载、唯一 pending migration、受影响主数据快照、B2 env/DB 初始状态。
|
||||
- B1:init 前后 migration status、schema/seed/account code 断言、build marker、runtime/external smoke、备份合同和角色化页面。
|
||||
- B2:env on + DB off、隔离部门 allowlist、七项合成业务合同、flag 快照恢复、关联数据保留、全局启用后复核。
|
||||
- 真实业务:四张单据退回说明逐单核对,重提后财务人工核验日期粒度;0008/0013 零改动。
|
||||
- 治理:task validate、Trellis governance、process contract、docs registry audit、敏感信息扫描和 `git diff --check`。
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "reimbursement-expense-policy-invoice-guard-deploy",
|
||||
"name": "reimbursement-expense-policy-invoice-guard-deploy",
|
||||
"title": "报销费用政策与票据门禁生产部署",
|
||||
"description": "受控部署 B1 migration 与费用主数据,在隔离部门验证 B2 后全局启用,并统一处置四张审批中报销。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "production deployment, migration, feature flag rollout, smoke identities, reimbursement remediation",
|
||||
"package": "deploy,server,client,trellis",
|
||||
"priority": "P0",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-08-30",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-29-reimbursement-expense-policy-invoice-guard",
|
||||
"relatedFiles": [
|
||||
"deploy/trialrun/deploy-existing.sh",
|
||||
"deploy/trialrun/deploy.sh",
|
||||
"server/prisma/migrations/20260829120000_reimbursement_expense_policy_invoice_guard/migration.sql",
|
||||
"server/src/scripts/seed-config.js",
|
||||
"server/src/services/featureFlags/index.js",
|
||||
"server/src/routes/reimbursements.js"
|
||||
],
|
||||
"notes": "规划阶段;不执行未单独授权的 push、生产账号/部门/合成数据写入、migration、部署、env 配置、feature flag 更新或真实单据退回。固定应用回滚基线为 59dc5f6。B2 不校验日期粒度,0014 必须依赖退回说明和财务人工复核。employee/manager/finance smoke 终态常驻‘系统测试专用(勿分配员工)’部门;每次灰度前成员集合必须恰好为三个 smoke。finance smoke 含付款能力,仅在获批验收窗口临时 active,窗口外 disabled。普通 employee 无直属 manager 时 workflow 会以 no_manager_found 阻断。B2 DB 记录必须显式存在为 force_off,灰度必须记录 actor 级精确 reason。",
|
||||
"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,76 @@
|
||||
# Design:B-2a 报销审批内容工作台
|
||||
|
||||
## 1. Change Boundary
|
||||
|
||||
- 扩展 workflow context 的显式预算快照投影,但不改 Prisma schema。
|
||||
- 审批页继续读取 workflow task;报销内容使用现有 reimbursement detail 只读 API。
|
||||
- 新建共享展示模型、分组纯函数和响应式明细证据组件。
|
||||
- PC/移动审批容器接入同一组件;审批 action API、状态机和 comment 存储不变。
|
||||
|
||||
## 2. Data Flow
|
||||
|
||||
```text
|
||||
GET workflow task
|
||||
-> task/actions/history
|
||||
-> submissionSnapshot { standardViolations, budgetCheck }
|
||||
|
||||
GET reimbursement detail
|
||||
-> current header/items/invoices/attachments
|
||||
-> current invoice amount evidence/manual review
|
||||
|
||||
pure projection
|
||||
-> itemEvidenceModels
|
||||
-> unlinkedInvoices
|
||||
-> submissionCheckSummary
|
||||
-> currentEvidenceSummary
|
||||
|
||||
PC/Mobile approval shell
|
||||
-> shared evidence component
|
||||
-> existing material open handlers
|
||||
-> existing approve/return/reject/transfer mutations
|
||||
```
|
||||
|
||||
两个查询任一缺少关键数据时不允许“通过”。材料单项预览失败不清空已经加载的其他证据。
|
||||
|
||||
## 3. Proposed Modules
|
||||
|
||||
- `client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx`:共享响应式内容组件。
|
||||
- `client/src/lib/reimbursementApprovalProjection.ts`:最小输入的分组、快照映射、实时摘要和退回引用纯函数。
|
||||
- `client/src/pages/WorkflowTaskDetailPage.tsx`:PC 审批壳与右侧决策区。
|
||||
- `client/src/pages/MobileApprovalsPage.tsx`:移动审批壳与底部动作层级。
|
||||
- `client/src/api/workflow.ts`:显式 `budgetCheck` 类型。
|
||||
- `server/src/routes/reimbursements.js`:提交时把已计算预算结果写入 context。
|
||||
- `server/src/routes/workflow.js`:只投影允许的预算快照字段。
|
||||
|
||||
具体文件可在实施前按现有复用点微调,但不得把共享组件放进 workflow 页面目录。
|
||||
|
||||
## 4. Permission Matrix
|
||||
|
||||
| 身份/状态 | 审批页内容 | 材料原件 | 报销维护 | 审批动作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 当前审批人 pending | 可读 | 按现有授权/审计 | 无 | 按 allowedActions |
|
||||
| 已处理任务查看者 | 可读当前版本 + 历史提示 | 按现有授权/审计 | 无 | 无 |
|
||||
| `expense.view_all` 财务 | 可读 | 按现有授权/审计 | 无 | 仅任务允许时 |
|
||||
| 无任务/无查看资格 | 403 | 403 | 无 | 无 |
|
||||
|
||||
## 5. Offline Boundary
|
||||
|
||||
报销审批的完整证据不进入现有 workflow IndexedDB cache。`cacheWorkflowTaskDetail` 必须先把 task detail 投影为显式允许字段子集,只保留任务身份、业务公开摘要、节点/状态和必要历史;明确删除或拒绝明细、票据、附件、金额证据、材料访问数据及共享 component projection。不能把整个 DTO 透传给 IndexedDB。
|
||||
|
||||
离线时可查看已经允许缓存的摘要/历史,但报销审批的通过、退回、驳回和保存草稿全部不可用,并说明“需联网核对完整报销内容”。其他实体继续沿用当前离线草稿能力。
|
||||
|
||||
若未来需要离线报销审批,必须重新规划敏感数据缓存字段、过期、清除、加密/设备边界和附件不可离线预览,不得在 B-2a 实施中临时扩大缓存。
|
||||
|
||||
## 6. Test Contracts
|
||||
|
||||
- projection 纯函数覆盖:零/一/多票、未归属、snapshot itemId 找不到、manual review、amount unavailable、历史预算缺失、退回引用去 UUID。
|
||||
- 后端覆盖:budgetCheck 与 workflow instance 同事务写入;null/旧 context 安全投影;不返回任意 context 字段。
|
||||
- PC/移动 E2E 使用同一 fixture 工厂,fixture 字段由 API type/服务端 route contract 共同约束,避免两端测试作者各造一套形状。
|
||||
- 完整流程断言必须点击真实可见的证据入口和审批动作;不能只断言 DOM 中存在文本。
|
||||
- 离线测试在在线打开合成报销审批后读取 IndexedDB 原始记录,断言禁存字段不存在;再断网验证报销动作/草稿全部阻断,同时保留非报销审批离线基线。
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
- 代码回滚恢复旧审批摘要和外链;workflow context 多出的 JSON 字段对旧代码无害。
|
||||
- 不含 migration、seed、flag 或生产数据改写。
|
||||
- 部署前/后按项目现有 build SHA、UI strict、公网 bundle 和合成审批任务流程核验。
|
||||
@@ -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,36 @@
|
||||
# Implementation Plan:B-2a 报销审批内容工作台
|
||||
|
||||
## Before Development
|
||||
|
||||
- [x] 用户批准父任务及 B-2a 最新 PRD/design/implement。
|
||||
- [x] 运行 `trellis-before-dev`,读取 frontend、approval workflow、reimbursement/OCR 和 cross-layer/code-reuse 规约。
|
||||
- [x] 用合成角色复核 workflow task 可见者读取 reimbursement detail 的权限矩阵。
|
||||
- [x] 冻结共享组件最小输入字段与 PC/移动页面结构。
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. [x] 后端把现有 `budgetCheck` 最小字段写入 workflow context,并在任务详情显式投影;补旧数据兼容单测。
|
||||
2. [x] 建立共享 projection 纯函数与测试:明细分组、票据归属、快照/实时提示、退回引用。
|
||||
3. [x] 建立响应式共享明细证据组件,只实现 review/readonly 能力和材料打开回调。
|
||||
4. [x] PC 审批页接入双查询、主列内容和右侧 sticky 决策区;关键数据失败时 fail closed。
|
||||
5. [x] 移动审批页接入同一组件,调整底部主/次动作;按允许字段投影缓存 workflow 摘要,排除全部报销证据,并在离线/内容未加载时阻断报销审批动作和草稿。
|
||||
6. [x] 恢复提交时费用标准行内提示、预算快照、办公电子设备提示和当前票据检查。
|
||||
7. [x] 增加明细/票据引用到现有退回意见的交互,不改后端 comment 合同。
|
||||
8. [x] 更新冲突的 frontend spec:旧“仅提供详情出口”改为共享内容工作台合同;保留完整原始表单次要入口。
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] 后端聚焦 Vitest + `npm run prisma:validate` + 相关 `node --check`。
|
||||
- [x] 前端组件/纯函数测试、`npm run typecheck`、`npm run build`。
|
||||
- [ ] PC/移动 Playwright 覆盖完整证据查看、快照/实时冲突、加载失败、材料打开、退回引用和动作层级。(PC 合成 2/2 已通过;移动完整环境待跑)
|
||||
- [x] 移动离线回归直接检查 IndexedDB 缓存字段,证明报销证据未落盘;既有非报销离线草稿用例保留在同一回归文件。
|
||||
- [x] 权限矩阵覆盖当前审批人、已处理查看、财务只读和无权身份。
|
||||
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。
|
||||
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、Trellis governance、敏感内容检查、`git diff --check`。
|
||||
- [x] 实施后因触及审批 workflow context 与权限读取链,执行只读 reviewer 复核;inline 模式由主会话按规则完成只读复核,不派发实现/check 子代理。
|
||||
|
||||
## Deployment Boundary
|
||||
|
||||
- [ ] 无用户单独部署授权时只提交本地代码与元数据,不 push、不部署。
|
||||
- [ ] 部署使用独立候选和既有生产回滚基线;无 migration/seed/flag。
|
||||
- [ ] 公网使用合成报销任务验证 PC/移动完整流程,不修改真人报销或审批状态。
|
||||
@@ -0,0 +1,113 @@
|
||||
# B-2a 报销审批内容工作台
|
||||
|
||||
## Goal
|
||||
|
||||
先把主管和财务每天真实使用的报销审批页改成可独立完成核验与决策的一页式工作台,并产出后续填写态必须采用的共享“明细 + 票据”组件合同。
|
||||
|
||||
## Scope
|
||||
|
||||
- PC `WorkflowTaskDetailPage` 和移动 `MobileApprovalsPage` 的报销任务详情。
|
||||
- 一个 PC/移动共用、审批/填写可迁移的报销明细证据组件。
|
||||
- 提交时费用标准/预算快照与当前票据事实的分层投影。
|
||||
- 票据/附件原件查看、材料合并预览入口、行内风险提示和审批动作布局。
|
||||
- 从具体明细或票据引用到现有退回意见的轻量交互。
|
||||
- 为新提交 workflow 补充最小预算快照;无 Prisma schema 或 migration。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1 首屏按决策顺序组织
|
||||
|
||||
PC 主列顺序固定为:
|
||||
|
||||
1. 报销摘要与事由;
|
||||
2. 当前报销明细及所属票据;
|
||||
3. 未归属票据与补充附件;
|
||||
4. 提交时检查与当前票据检查;
|
||||
5. 处理历史。
|
||||
|
||||
PC 右侧 sticky 区固定为:状态与当前节点、报销合计、合并检查摘要、审批意见、通过/退回/驳回/更多。提交快照与当前票据事实只有不一致时才拆出差异提示。完整审批流和处理历史始终放在主列证据之后。
|
||||
|
||||
PC 只保留这一处动作面板:`通过`为主按钮,`退回`、`驳回`为紧凑次操作,`转审`进入“更多”,不得在主列底部重复按钮或把动作竖向铺成卡片。窄屏降单列时,同一动作面板转到底部;移动端采用单列内容和底部单一主操作。
|
||||
|
||||
### R2 明细和票据必须同屏对应
|
||||
|
||||
- 每条明细显示费用类型、发生日期、金额、说明、assets 事前批准依据(如有)和所属票据。
|
||||
- 票据显示员工可理解的名称、日期、票面金额、状态和原件入口;不显示内部字段名或禁用术语。
|
||||
- `linkedItemId=null` 的票据进入醒目的“尚未对应明细”分组,不隐藏在整单材料列表中。
|
||||
- 补充附件作为整单证据单独展示,不伪造与某条明细的结构化关系。
|
||||
- “打开完整原始表单/打印视图”作为次要入口保留,不得替代上述内容。
|
||||
|
||||
### R3 两类检查不能混用
|
||||
|
||||
- `workflowInstance.contextData.standardViolations` 作为费用标准提交快照,按 `itemId` 就近映射。
|
||||
- 新提交把已经计算的 `budgetCheck` 以最小字段集写入 workflow context;审批端只读该快照。历史无快照时明确显示缺失,不调用实时预算结果冒充。
|
||||
- 当前票据归属、金额证据、人工核对状态和材料数量来自实时报销详情。
|
||||
- 当前页面不主动调用受参与人/`expense.view_all` 保护的 precheck 来重算费用标准;不得放宽 precheck 授权。
|
||||
- 办公用品达到既有阈值的非阻断提示由纯前端确定性规则直接就近显示。
|
||||
|
||||
### R4 数据加载失败必须 fail closed
|
||||
|
||||
- workflow 任务摘要加载成功但实时报销内容加载失败时,页面显示可恢复错误和重试入口。
|
||||
- 在完整报销内容、当前票据检查和提交快照解析完成前,报销审批的通过、退回、驳回和审批草稿均不可执行;不得误称已经核验完整内容。
|
||||
- 单张票据原件加载失败只阻断该证据查看并给出重试/打开完整表单退路,不使整个页面崩溃。
|
||||
|
||||
### R5 退回定位复用自由文本合同
|
||||
|
||||
- 每条明细和每张票据可触发“引用到退回意见”,生成稳定、无 UUID 的员工可读引用。
|
||||
- 多个引用可以合并到同一退回意见;审批人仍可自由补充说明。
|
||||
- 实际提交仍调用现有退回接口和 comment 字段;不新增后端 reason 结构。
|
||||
|
||||
### R6 PC/移动与权限一致
|
||||
|
||||
- 当前审批人和具备既有查看资格的用户只读核验报销内容,不获得任何报销人维护动作。
|
||||
- PC 与移动端使用同一内容组件和数据分层函数;页面容器只负责导航、响应式布局及审批动作。
|
||||
- 发票/附件预览继续执行现有权限、用途、审计和短时 URL 合同。
|
||||
- 已处理任务仍可查看当前报销内容,但必须提示“当前版本可能包含退回后修改”;历史审批意见和提交时检查保持各自历史语义。
|
||||
|
||||
### R7 移动可访问性与操作层级
|
||||
|
||||
- 底部只保留一个主操作;退回、驳回、转审进入明确的次级入口和确认流程。
|
||||
- 所有证据入口和操作触控区域至少 44×44,键盘/读屏顺序与视觉顺序一致。
|
||||
- 警告不能只靠红/黄颜色区分,必须有文本状态和处理影响;固定操作区不遮挡最后一项内容。
|
||||
|
||||
### R8 移动离线缓存采用显式最小投影
|
||||
|
||||
- 报销审批必须联网加载完整明细与票据后才能通过、退回、驳回或保存审批草稿;离线时只显示摘要/历史和“需联网核对完整报销内容”的解释。
|
||||
- `cacheWorkflowTaskDetail` 不得无条件持久化扩展后的整个 task detail。缓存写入前必须构造允许字段子集,并明确排除报销明细、发票、附件、票面金额证据、材料 URL/访问参数和共享组件 projection。
|
||||
- 测试必须直接读取 IndexedDB 的 `workflow_task_cache` 记录,断言禁存字段不存在;不能只断言离线按钮禁用。
|
||||
- 非报销审批继续沿用当前离线草稿能力,避免本任务扩大为全审批离线重构。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] PC 报销审批详情不再只有四字段摘要;审批人无需跳转即可看到事由、全部明细、所属票据、未归属票据、附件、检查和历史。
|
||||
- [ ] 移动报销审批详情提供同等业务内容,字段顺序与 PC 一致,无桌面表格和横向滚动。
|
||||
- [ ] 明细、票据和提示按对象就近展示;`standardViolations` 和办公电子设备提示恢复到具体明细。
|
||||
- [ ] 新提交 workflow 保存最小预算快照;历史无快照明确显示缺失,审批页不以实时预算替代。
|
||||
- [ ] 票据关联和金额证据使用实时详情;退回补票/重绑后刷新即可看到最新事实。
|
||||
- [ ] workflow 摘要成功而报销详情失败时,“通过”不可执行并提供重试,不退回四字段审批。
|
||||
- [ ] 票据和附件可按既有授权查看;非参与审批人看不到编辑、删除、关联或解绑动作。
|
||||
- [ ] 审批人可从明细/票据引用到自由文本退回意见,最终后端存储合同不变。
|
||||
- [ ] PC 只有右侧单一 sticky 动作面板,主列底部无重复按钮;完整审批流/历史在主列靠后,动作横向紧凑且“转审”进入更多。移动底部操作在代表性视口不遮挡内容,状态不只靠颜色表达。
|
||||
- [ ] 报销审批离线时所有决策/草稿入口不可用,IndexedDB workflow 缓存不含明细、票据、附件或材料证据;其他审批类型离线基线保持成功。
|
||||
- [ ] 非报销审批详情、审批状态机、权限码、B2 门禁、付款和真实单据状态不变。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 把 PC/移动报销填写态迁入共享组件;该项属于 B-2b。
|
||||
- 票据抽屉中的新增/编辑/拖拽、由票生成明细和填写态提交前检查改造。
|
||||
- 结构化退回原因、数据库迁移和退回统计。
|
||||
- 新增财务代员工编辑或纠正票据归属的写入口。
|
||||
- 生产部署和真人数据验证。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 后端:workflow 任务详情快照投影、提交时预算快照写入、历史缺失兼容、权限与状态零回归。
|
||||
- 前端单元/组件:明细—票据分组、未归属票据、快照/实时标签、历史版本提示、引用退回意见、加载失败 fail closed。
|
||||
- Playwright PC:从审批列表进入报销任务,直接查看明细/票据/附件,打开原件,引用退回意见,检查右侧 sticky 动作区。
|
||||
- Playwright 移动:同一数据合同、底部主操作、次级动作、44px 触控区、无横向溢出、固定区不遮挡;在线加载前/离线时不能形成报销审批动作或草稿。
|
||||
- 离线存储:在线打开合成报销审批后直接读取 `workflow_task_cache`,断言明细、票据、附件、金额证据和材料访问字段未持久化;非报销审批离线草稿回归继续通过。
|
||||
- 合成冲突用例:提交快照显示超标而当前实时票据已变化,断言两类信息都存在且标签正确。
|
||||
- 权限用例:审批人可读证据但无报销维护动作;无权用户仍 403。
|
||||
- 前端门禁:`cd client && npm run typecheck && npm run build`、聚焦 Playwright、`node scripts/qa/check-ui-design-rules.mjs --strict`、`--self-test`。
|
||||
- 后端门禁:相关 Vitest、`cd server && npm run prisma:validate`、必要的 `node --check`。
|
||||
- 治理:task validate、process contract、Trellis governance、敏感内容检查、`git diff --check`。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-approval-content-workbench",
|
||||
"name": "reimbursement-approval-content-workbench",
|
||||
"title": "B-2a 报销审批内容工作台",
|
||||
"description": "在 PC/移动审批详情中直接展示报销明细、所属票据、附件与快照/实时核验,并建立共享内容组件。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client,server,spec",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "c840574",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-01-reimbursement-one-page-approval-workbench",
|
||||
"relatedFiles": [],
|
||||
"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,188 @@
|
||||
# Design:B-2b 统一报销工作台迁移
|
||||
|
||||
## 1. Architecture Decision
|
||||
|
||||
采用“任务路由分派 + 唯一报销工作台内容合同 + PC/移动布局壳 + 三个模式适配器”。不把审批模式继续塞进现有大型详情页,也不让通用 workflow 页面继续拥有报销业务布局。
|
||||
|
||||
```text
|
||||
/workflow/tasks/:taskId /mobile/approvals/:taskId
|
||||
│ │
|
||||
└──── authorized task route dispatcher ┘
|
||||
│
|
||||
entityType=reimbursement
|
||||
│
|
||||
ReimbursementApprovalAdapter
|
||||
│
|
||||
ReimbursementDesktopWorkspaceShell
|
||||
(共享内容合同)
|
||||
|
||||
/reimbursements/:reimbursementId
|
||||
│
|
||||
reimbursement route selector
|
||||
├─ participant + draft/rejected -> ReimbursementEditAdapter
|
||||
└─ otherwise authorized -> ReimbursementReadonlyAdapter
|
||||
│
|
||||
Desktop / Mobile WorkspaceShell
|
||||
(共享内容合同)
|
||||
```
|
||||
|
||||
任务分派器通过唯一一次已授权 task detail 查询选择实体渲染器,并把同一份 query result(含受控 refetch 能力)移交给所选渲染器。它不重复查询、不渲染报销摘要、不计算报销检查、不持有审批表单状态。非报销实体继续进入现有通用详情。
|
||||
|
||||
## 2. Shell Contract
|
||||
|
||||
PC/移动布局壳都是纯组合组件,共享模型和 capability 合同,但不强求同一份 DOM,也不接收 `mode` 用于业务判断:
|
||||
|
||||
```ts
|
||||
type ReimbursementWorkspaceModel = {
|
||||
header: ReimbursementWorkspaceHeaderModel;
|
||||
evidence: ReimbursementWorkspaceEvidenceProjection;
|
||||
issueSummary: ReimbursementWorkspaceIssueSummary | null;
|
||||
timeline: ReimbursementWorkspaceTimelineModel;
|
||||
};
|
||||
|
||||
type ReimbursementWorkspaceCapabilities = {
|
||||
item?: ReimbursementEvidenceItemCapabilities;
|
||||
invoice?: ReimbursementEvidenceInvoiceCapabilities;
|
||||
attachment?: ReimbursementEvidenceAttachmentCapabilities;
|
||||
decision?: ReimbursementDecisionCapabilities;
|
||||
};
|
||||
|
||||
type ReimbursementWorkspaceShellProps = {
|
||||
model: ReimbursementWorkspaceModel;
|
||||
capabilities: ReimbursementWorkspaceCapabilities;
|
||||
primaryActions?: ReactNode;
|
||||
secondaryActions?: ReactNode;
|
||||
statusSummary?: ReactNode;
|
||||
transientFeedback?: ReactNode;
|
||||
};
|
||||
```
|
||||
|
||||
`ReimbursementDesktopWorkspaceShell` 与 `ReimbursementMobileWorkspaceShell` 可以根据数据是否存在组合区块,也可以渲染注入的 slot;两者必须保持同一业务内容顺序和语义子组件,但可分别实现 PC 右栏与移动卡片/底部安全区布局。它们不能根据角色、permission、workflow 状态、报销状态或模式推导能力,不能用 `isMobile` 业务分支互相模拟。壳内禁止 `useQuery`、`useMutation`、API client 和业务 mutation state。纯视觉展开优先使用语义 HTML;确需本地视觉状态时不得承载业务事实。
|
||||
|
||||
## 3. Adapter Responsibilities
|
||||
|
||||
### Edit Adapter
|
||||
|
||||
- 获取/刷新报销详情、费用类型、项目、票据候选和 participant precheck。
|
||||
- 拥有表头、明细、票据、附件、提交、删除、OCR 状态与错误恢复 mutation/state。
|
||||
- 将可用动作投影为 capabilities;状态或权限不允许时不注入,不让壳二次判断。
|
||||
- 服务端提交/B2 结果覆盖客户端乐观判断。
|
||||
|
||||
### Approval Adapter
|
||||
|
||||
- 消费路由边界移交的 workflow task detail/query result,不为同一 task 再发一份分派查询;另行获取实时报销详情。
|
||||
- 拥有 task detail 的业务解释和刷新策略、allowedActions、提交快照、处理历史、审批 mutation、退回动作内引用、材料查看、联网状态和 fail-closed。
|
||||
- 常驻内容只注入证据查看和审批决策能力;引用只注入已展开的退回编辑区,编辑/关联/解绑 capability 必须不存在。
|
||||
- 任务、快照、实时报销内容任一关键部分未就绪时,输出不可决策模型和原因。
|
||||
|
||||
### Readonly Adapter
|
||||
|
||||
- 只获取既有授权允许的报销详情、处理进度和材料查看能力。
|
||||
- 不请求参与人专用 precheck,不持有任何业务 mutation。
|
||||
- 不注入编辑、提交或 workflow 决策能力。
|
||||
|
||||
共享查询键、材料打开 helper、展示投影和格式化函数可以复用;适配器不能通过一个巨大 hook 返回所有模式的 mutation 再由页面隐藏。
|
||||
|
||||
## 4. Information Architecture
|
||||
|
||||
### PC
|
||||
|
||||
```text
|
||||
报销单号 + 状态
|
||||
报销人 · 日期 · 合计
|
||||
事由
|
||||
┌────────────────────────────────────┬────────────────────┐
|
||||
│ 费用明细表 │ 当前节点/状态 │
|
||||
│ 票据与附件(发票/补充附件分组) │ 可决策状态 │
|
||||
│ 未归属恢复(仅有时) │ 异常锚点(仅有时) │
|
||||
│ 异常/快照差异(仅有时) │ 审批/提交动作 │
|
||||
│ 处理历史/进度 │ sticky │
|
||||
└────────────────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
### Mobile
|
||||
|
||||
标题/状态 → 报销人/日期/合计 → 事由 → 费用明细卡 → 票据与附件(分组)→ 未归属恢复(仅有时)→ 异常/差异(仅有时)→ 历史/进度 → 底部动作。
|
||||
|
||||
正常检查不生成卡片。异常模型必须带对象引用和锚点:
|
||||
|
||||
```ts
|
||||
type ReimbursementWorkspaceIssue = {
|
||||
key: string;
|
||||
target: { kind: "item" | "invoice" | "submission"; displayLabel: string };
|
||||
severity: "blocking" | "review" | "info";
|
||||
reason: string;
|
||||
impact: string;
|
||||
nextStep?: string;
|
||||
anchorId: string;
|
||||
};
|
||||
```
|
||||
|
||||
右栏只投影非零、可定位的问题。快照缺失是 `info`,视觉低于 blocking/review;快照与实时事实一致时不生成独立差异区。
|
||||
|
||||
## 5. Route and Navigation
|
||||
|
||||
- PC `/workflow/tasks/:taskId` 与移动 `/mobile/approvals/:taskId` 保持不变,避免破坏通知、待办、刷新和深链。
|
||||
- 分派期间显示统一加载骨架;不得先挂载 `WorkflowTaskDetailPage`/通用移动详情再切换报销工作台。
|
||||
- `/reimbursements/:id` 继续是报销记录入口。返回目标由合法、受限的 navigation state/source helper 解析;审批工作台不再提供“返回审批详情”的自循环外链。
|
||||
- 新增或重分类路由时同步 `scripts/qa/route-manifest.json`;如果只是同一路由内分派,也要为 reimbursement renderer 增加行为证据。
|
||||
|
||||
## 6. Legacy Removal
|
||||
|
||||
最终候选删除:
|
||||
|
||||
- `WorkflowTaskDetailPage` 的报销 DetailField、报销证据/检查区、报销右栏摘要与报销专用动作分支。
|
||||
- `MobileApprovalsPage` 的报销内容、报销检查、报销离线解释和报销动作业务分支;通用列表/非报销详情可保留。
|
||||
- `ReimbursementDetailPage`/`MobileReimbursementDetailPage` 中被工作台替代的旧明细表格和旧材料分组。
|
||||
- 旧报销专用文案、测试 fixture 与只为休眠路径服务的 helper/import。
|
||||
|
||||
允许在实现过程的未提交工作区短暂双接线;业务候选提交前必须用结构测试/`rg` allowlist 证明旧分支为 0。不得保留 feature flag 双轨。
|
||||
|
||||
上述壳层禁用项与通用审批旧分支清零必须实现为 `scripts/qa/check-ui-design-rules.mjs` 的 error 规则,并通过既有 `--strict` 部署 preflight;单元测试只补行为和类型证据,不能替代该门禁。
|
||||
|
||||
## 7. Data and Permission Contracts
|
||||
|
||||
- 不改后端 schema。现有 reimbursement detail、workflow detail、材料预览和动作 API 保持权威。
|
||||
- 提交时费用标准/预算来自 workflow context 快照;当前票据/附件来自 reimbursement detail。
|
||||
- approval adapter 复用 B-2a IndexedDB 白名单投影;工作台 model/evidence 不进入 cache。
|
||||
- edit adapter 继续要求参与人 + `draft/rejected` + 现有 permission;readonly/approval 不因 UI 复用获得写能力。
|
||||
- 材料 URL、访问参数和原始 payload 不进入 task、日志、浏览器持久缓存或错误文案。
|
||||
|
||||
## 8. Print Boundary
|
||||
|
||||
`/reimbursements/:id/print` 继续作为专用打印/PDF 视图,允许独立的纸面布局。它不参与三模式交互工作台,不持有工作台 mutation,也不作为审批取得核心内容的入口。本轮只做回归,避免字段删除误伤打印;不迁移或重新设计打印结构。
|
||||
|
||||
## 9. Rollout and Rollback
|
||||
|
||||
- 单一独立候选上线,不新增 migration、seed、flag 或生产数据写入。
|
||||
- 切换前必须完成 PC/移动三模式和非报销回归;上线后使用公网真实 bundle + 合成 API 验收,不操作真人单据。
|
||||
- 回滚是完整回到 B-2a 已部署版本;不保留运行时双壳开关。
|
||||
- 真实三张 rejected 单据可与开发并行,任何反馈只通过新的规划修订进入本任务,不直接现场改产品代码。
|
||||
|
||||
## 10. UI/UX Basis
|
||||
|
||||
本任务不引入新视觉系统。采用 `ui-ux-pro-max` 对本场景适用的通用原则:一致标题层级、组合优于模式继承、异常不只靠颜色、反馈明确、触控目标至少 44×44、渐进披露避免把所有解释预先铺开。其数据库没有命中特定“审批工作台”React 模板,因此不采用不存在的模板结论,继续以项目现有组件和 `docs/产品设计规则.md` 的信息架构为基线。
|
||||
|
||||
## 11. Post-deployment KISS Evidence Layout
|
||||
|
||||
上线真人目视反馈证明,统一工作台架构正确,但 `ReimbursementItemEvidenceList` 仍把“费用明细、所属票据、文件动作、退回引用”压进同一嵌套卡片。收尾迭代保持适配器和 capability 边界不变,把展示层拆成三个语义组件:
|
||||
|
||||
```text
|
||||
ReimbursementEvidenceSection
|
||||
├─ ReimbursementExpenseItems
|
||||
│ ├─ desktop: ExpenseItemsTable
|
||||
│ └─ mobile: ExpenseItemCards
|
||||
├─ ReimbursementMaterials
|
||||
│ ├─ invoices
|
||||
│ └─ supplementary attachments
|
||||
└─ ReimbursementReturnReferencePicker(仅退回动作展开时挂载)
|
||||
```
|
||||
|
||||
- `ExpenseItemsTable` 与 `ExpenseItemCards` 消费同一行模型:类型、日期、说明、金额、票据数量/状态、异常摘要、稳定锚点。PC/移动可以不同 DOM,不得各自重新计算业务状态。
|
||||
- 发票不再嵌套在明细 DOM 内;projection 继续保留 `linkedItemId`,由纯函数派生每行票据摘要和独立材料列表。点击问题锚点仍能定位到对应明细行或材料行。
|
||||
- 文件行的文件名/整行采用语义按钮或链接作为唯一查看入口;视觉上不重复“查看原件/查看附件”。移动命中区域不小于 44px,键盘焦点和 `aria-label` 必须包含材料类型与文件名/票号。
|
||||
- edit capability 映射为明细单一编辑入口和材料区维护入口;readonly/approval 不注入维护能力。未归属或阻断票据是例外,可在材料行就地显示关联恢复动作。
|
||||
- `onReferenceItem` / `onReferenceInvoice` 不再传给常驻明细/材料组件;审批适配器仅在 `activeSecondaryAction === "return"` 时把异常优先的候选模型传给引用选择器。选择器只写入本地退回意见文本,不调用 API。
|
||||
- “查看完整材料”是区块级批量预览能力,与单文件入口并存时必须保持次要层级;单文件打开仍沿用既有短时 URL、用途审计和失败隔离。
|
||||
|
||||
参考 `/home/shunli/shenzhou-business-suite` 的费用明细表、移动卡片和附件分区只用于确定信息架构。实现不依赖该项目代码,也不复制其权限、状态或样式实现。
|
||||
@@ -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,77 @@
|
||||
# Implementation Plan:B-2b 统一报销工作台迁移
|
||||
|
||||
## Before Development
|
||||
|
||||
- [x] 用户批准最新 `prd.md`、`design.md`、`implement.md`。
|
||||
- [x] 切换当前 Trellis task 到 B-2b 并运行 `task.py start`;实施前加载 `trellis-before-dev`。
|
||||
- [x] 读取 frontend、approval workflow、reimbursement/OCR、security/privacy、code reuse 与 route manifest 规约。
|
||||
- [x] 冻结现有 PC/移动三模式、权限/状态、路由/返回和打印页回归矩阵。
|
||||
- [x] 记录 B-2a 当前 DOM/test-id、公共 projection、IndexedDB 投影与可回滚提交基线。
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. [x] 先在 `scripts/qa/check-ui-design-rules.mjs` 建立 error 级结构门禁:PC/移动布局壳禁止 query/mutation/API/role/status/mode 请求分支;通用审批页最终禁止报销业务渲染;旧分支允许清单仅覆盖分派器,并补 self-test。
|
||||
2. [x] 抽取统一报销头、异常模型、快照/实时差异和三模式共享展示 projection 纯函数;补正常/异常/缺失边界测试。
|
||||
3. [x] 建立无业务状态的 PC/移动布局壳,共享模型、区块顺序、`ReimbursementItemEvidenceList` 与 capability 合同;PC 实现右栏 sticky,移动实现卡片 + 底部安全区,不复用同一 DOM 或引入 `isMobile` 业务分支。
|
||||
4. [x] 在 PC/移动 task route 建立唯一 task detail 查询和授权后实体分派;把同一 query result/refetch 入口移交渲染器,报销进入 approval adapter,非报销继续通用详情;避免重复 task 查询和通用页闪现。
|
||||
5. [x] 建立 approval adapter:消费已移交的 task detail,另取实时报销 detail,迁移 allowedActions、快照、task 刷新策略、fail-closed、退回引用、材料查看与 IndexedDB/离线合同。
|
||||
6. [x] 删除 `WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务内容、检查、右栏摘要和休眠兜底;立即跑非报销审批回归。
|
||||
7. [x] 建立 readonly adapter,把获准只读报销详情、材料查看和处理进度接入同一工作台,不请求参与人 precheck,不注入 mutation。
|
||||
8. [x] 建立 edit adapter,迁移表头/明细/票据/附件维护、票据抽屉、未归属恢复、提交前检查和 B2 阻断后再次提交;所有能力通过 capability 注入。
|
||||
9. [x] 删除 PC/移动旧明细表格、旧报销材料分组和被替代 helper/test fixture;确保最终候选无双实现或 feature flag 兜底。
|
||||
10. [x] 落实七条信息收敛:紧凑头、唯一事由、正常项隐藏、异常就近、差异条件显示、右栏去重、动作解释按需;补可访问性和锚点/focus 回归。
|
||||
11. [x] 回归 `/reimbursements/:id/print`,证明打印页未被工作台拆分误伤且没有获得交互 mutation。
|
||||
12. [x] 更新与新合同冲突的父任务、frontend/approval/reimbursement spec 或产品事实源;不顺带处理无关治理债。
|
||||
|
||||
## Verification Matrix
|
||||
|
||||
### Structural
|
||||
|
||||
- [x] `check-ui-design-rules.mjs --strict` 对 PC/移动壳中的 `useQuery|useMutation|api/|roleCodes|hasPermission|workflow status|reimbursement status|mode ===` 命中报 error(仅显式视觉 allowlist 可例外),并由 self-test 证明规则有效。
|
||||
- [x] task route 每次详情加载只发一份授权 task detail 查询;approval adapter 复用该结果和 refetch 入口,不产生同 key 重复请求。
|
||||
- [x] 三适配器 capability 快照准确:edit 有维护/提交、approval 有查看/引用/决策、readonly 只有查看。
|
||||
- [x] 通用 PC/移动审批详情不再导入报销证据组件或包含报销摘要/检查文案;唯一 `reimbursement` 判断位于授权分派层允许清单。
|
||||
- [x] 旧明细表/材料布局与休眠测试 fixture 删除,没有双实现 flag。
|
||||
|
||||
### Behavior
|
||||
|
||||
- [x] PC/移动 edit:draft/rejected 新增/编辑/删除明细、PDF 发票上传/选择、关联/解绑、未归属恢复、附件维护、提交预检、B2 阻断、修复后再次提交。
|
||||
- [x] PC/移动 readonly:参与人 reviewing 与非参与人获准查看;无维护、无审批能力;材料权限/审计不变。
|
||||
- [x] PC/移动 approval:pending/已处理、快照 ready/missing/invalid、detail failure、材料单项 failure、退回引用、approve/return/reject/transfer 和联网 fail-closed。
|
||||
- [x] 非报销 workflow:请假、补卡、采购、差旅通用详情和移动离线草稿保持基线。
|
||||
- [x] 路由:列表进入、通知深链、刷新、后退、移动分流、无权 403/不泄露、无返回循环。
|
||||
- [x] 信息:正常 fixture 无“0 条”检查/常驻动作解释;异常 fixture 每项都有对象、原因、影响、下一步和可聚焦锚点;只有快照/实时不一致时出现差异。
|
||||
|
||||
### Commands
|
||||
|
||||
- [x] 相关前端单元/组件/Playwright;`cd client && npm run typecheck && npm run build`。
|
||||
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。
|
||||
- [x] route manifest/candidate gate、相关后端 B2/workflow/detail/material tests、`cd server && npm run prisma:validate`。
|
||||
- [x] `python3 ./.trellis/scripts/task.py validate 09-01-reimbursement-form-shared-component-migration`。
|
||||
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、敏感内容检查、docs registry(如需)、`git diff --check`。
|
||||
- [x] 因涉及 workflow 分派、权限/状态和报销动作,实施后执行只读 review;inline 模式由主会话完成,不派发 implement/check 子代理。
|
||||
|
||||
验证说明:移动离线专项的默认 iPhone 设备配置会选择本机未安装的 WebKit;本轮按既有口径改用 Chromium + iPhone 视口执行,3/3 通过。该结果验证 IndexedDB、离线分流和 fail-closed 合同,不替代 Safari/WebView 渲染验收。
|
||||
|
||||
## Commit and Deployment Boundary
|
||||
|
||||
- [ ] 代码检查通过后按中文 task 引用提交;task 元数据回填独立提交,不 amend。
|
||||
- [ ] 未经明确授权不 push、不部署、不写生产数据。
|
||||
- [ ] 部署候选无 migration/schema/flag/seed;使用既有生产入口与独立回滚基线。
|
||||
- [ ] 公网用合成三模式任务验证,不碰 `0016/0017/0018` 或其他真人单据。
|
||||
- [ ] 部署与真人反馈完成前,B-2b 和父任务都不得归档。
|
||||
|
||||
## Post-deployment KISS Closeout
|
||||
|
||||
1. [x] 把当前嵌套 `ReimbursementItemEvidenceList` 拆为共享行 projection、PC 明细表、移动明细卡和独立票据/附件区;不改壳、适配器查询或后端合同。
|
||||
2. [x] PC 审批/只读明细行清零常驻动作;编辑态收敛为单一编辑入口,删除进入编辑流程或二次确认。
|
||||
3. [x] 文件名/文件行成为唯一查看入口,删除重复“查看原件/查看附件”;保留区块级“查看完整材料”及异常票据就地恢复。
|
||||
4. [x] 将明细/票据引用能力移入退回编辑区,异常对象优先,全部对象按需展开;关闭或切换动作时保持现有意见状态合同。
|
||||
5. [x] 更新 PC/移动三模式与 B2 测试:正常行零动作、材料分组、单一查看入口、退回渐进披露、问题锚点、未归属恢复、键盘/触控/横屏。
|
||||
6. [ ] 复跑 typecheck/build、UI strict/self-test、相关 Playwright、B2/workflow 后端回归、治理与部署门;重新提交、push、部署均须分别批准。
|
||||
|
||||
## Rollback Points
|
||||
|
||||
- 适配器接线或非报销回归失败:停止在本地,不删除旧分支,修正后重新验证。
|
||||
- 最终旧分支删除后检查失败:回到本任务候选前提交,不引入运行时双轨 flag。
|
||||
- 公网验收失败:回滚到 B-2a 生产基线,保留数据库与业务数据不变。
|
||||
@@ -0,0 +1,155 @@
|
||||
# B-2b 统一报销工作台迁移
|
||||
|
||||
## Goal
|
||||
|
||||
把报销从“填写详情页、审批详情页、旧只读详情页分别组织同一业务对象”收敛为唯一的交互式报销工作台。工作台以报销内容为主体,通过 `edit`、`readonly`、`approval` 三个模式适配器注入数据、状态和能力;审批只是叠加在报销对象上的流程与决策能力,不再复制一套报销页面壳。
|
||||
|
||||
本任务优先服务当前唯一有真实流转的“报销 → 审批 → 付款”链路。请假/补卡继续以企业微信为主,采购/差旅保持冻结的低频能力,不因追求形式一致而同步重构。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- B-2a 已部署到生产并通过公网真实 bundle 的 PC/移动合成验收,解决了审批人必须跳转才能取得明细、票据和附件的问题。
|
||||
- B-2a 是经批准的过渡交付:先恢复核心内容可达性并产出 `ReimbursementItemEvidenceList`、快照投影、离线白名单与 fail-closed 合同;B-2b 负责根据真人目视反馈收敛页面结构。
|
||||
- 真人目视反馈确认:通用审批壳的流程摘要、报销六字段摘要、新证据区和右侧检查计数存在重复;解释性文字按系统内部状态平铺,审批人需要的“哪条明细、什么问题、是否阻断、如何处理”反而不突出。
|
||||
- `ReimbursementDetailPage.tsx` 与 `MobileReimbursementDetailPage.tsx` 已是高状态、高 mutation 的大型页面。继续在其中加入 `if (approvalMode)` 会把查询、权限、编辑和审批职责耦合成更难维护的巨型组件。
|
||||
- 项目定位决定采用方案 A:只有报销任务进入业务对象工作台的审批模式;其他 workflow 实体继续使用通用审批详情。
|
||||
- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 仍是审批深链和任务身份入口;报销任务在任务元数据解析后交给报销审批适配器,不要求用户再跳到另一业务路由。
|
||||
- B-2a 的提交时费用标准/预算快照与当前票据事实仍是两类不同时间语义,不因页面合并而混用。
|
||||
|
||||
## Entry Conditions
|
||||
|
||||
- B-2a 已部署并通过生产 `/ready`、公网 bundle 与合成业务验收。
|
||||
- 已取得一次真人目视反馈,并据此冻结方案 A、信息收敛规则和壳/适配器边界。
|
||||
- 本 PRD、`design.md` 与 `implement.md` 经用户复核批准后,B-2b 才可进入实施。
|
||||
- 三张真实 `rejected` 单据的员工修改与反馈可以并行推进,不是本任务启动或完成的阻塞条件;反馈如改变已批准产品行为,必须回到规划阶段修订。
|
||||
|
||||
## Scope
|
||||
|
||||
- 建立 PC/移动共享内容合同的唯一交互式报销工作台;PC 与移动允许使用各自布局壳,但必须消费同一展示模型、区块顺序、语义组件和 capability 契约。
|
||||
- 建立 `edit`、`readonly`、`approval` 三类模式适配器;查询、业务状态、权限判断、mutation、错误恢复和 fail-closed 均归适配器。
|
||||
- `/reimbursements/:reimbursementId` 的参与人填写态和非参与人只读态迁入工作台。
|
||||
- `/workflow/tasks/:taskId` 与 `/mobile/approvals/:taskId` 的报销任务进入同一工作台的审批适配器;其他实体继续走通用审批详情。
|
||||
- 复用 B-2a 的证据 projection、capability 契约、票据抽屉、提交前检查和未归属恢复;把嵌套的明细—票据卡片替换为费用明细与材料分区。
|
||||
- 删除 PC/移动通用审批页中的报销业务分支,以及被工作台替代的旧报销明细/材料布局。
|
||||
- 收敛顶部摘要、检查提示、右侧动作摘要和退回/驳回解释。
|
||||
|
||||
## Product Requirements
|
||||
|
||||
### R1 一个业务对象只保留一个交互式工作台
|
||||
|
||||
- 报销详情的主内容顺序在 `edit`、`readonly`、`approval` 三种模式中一致:紧凑报销头 → 费用明细 → 票据与附件 → 必要检查/差异 → 处理历史或处理进度。发票归属仍是业务合同,但不再以嵌套卡片占据每条明细的主视觉。
|
||||
- PC 宽屏使用主列证据 + 右侧单一 sticky 动作区;窄屏和移动采用同一内容顺序的卡片/分区详情布局,动作区转到底部且不遮挡内容。PC 与移动不强求同一份 DOM,不得用密集的 `isMobile` 业务分支把两种布局重新耦合成巨型壳。
|
||||
- `/workflow/tasks/:taskId` 保留为审批入口和浏览器地址。任务解析为 `reimbursement` 后由报销审批适配器渲染工作台;不得先渲染通用报销摘要再嵌入工作台,也不得要求用户点击“查看报销单”才能取得核心内容。
|
||||
- `/reimbursements/:reimbursementId` 使用同一工作台。参与人处于 `draft/rejected` 时获得编辑能力;其他获准查看者只得到只读能力。
|
||||
- “打开完整原始表单”不再作为独立核心内容页面入口;打印/PDF 仍通过专用打印路由提供。
|
||||
|
||||
### R2 壳与模式适配器必须严格分层
|
||||
|
||||
- PC/移动布局壳只接收同一份已经计算完成的展示模型、区块内容和 capability/action slots,分别负责各端布局、响应式结构和语义标题;内容顺序和业务组件合同不得分叉。
|
||||
- 工作台壳不得调用 `useQuery`、`useMutation` 或 API;不得持有编辑、审批、权限、状态机、票据上传或错误恢复等业务状态;不得根据 `mode` 决定是否发请求。
|
||||
- 壳内不以 `roleCodes`、permission code、workflow 状态、报销状态或 `if (mode === ...)` 推导业务能力。模式差异由适配器注入了什么数据、capability 和 action slot 体现。
|
||||
- `edit` 适配器拥有参与人判断、`draft/rejected` 状态、表头/明细/票据/附件维护状态和 mutation、提交前检查及 B2 错误恢复。
|
||||
- 任务路由边界唯一获取用于授权和实体分派的 workflow task detail,并把同一份查询结果及受控刷新入口移交给 `approval` 适配器;不得为分派和审批内容各发一次相同 task detail 请求。
|
||||
- `approval` 适配器拥有 workflow task detail 的业务解释、allowed actions、提交快照、审批 mutation、刷新策略、fail-closed、退回引用、处理历史与联网要求;不得获得报销维护 mutation。
|
||||
- `readonly` 适配器只执行获准的只读查询和材料查看,不注入新增、编辑、删除、关联、解绑、提交或审批能力。
|
||||
- 共享投影和展示组件接收显式最小字段或纯展示模型,不接收完整 Prisma/API 记录,也不在组件内部判断角色。
|
||||
|
||||
### R3 通用审批页的报销旧分支必须删除
|
||||
|
||||
- PC `WorkflowTaskDetailPage` 中报销专用的六字段 `DetailField`、报销证据区、提交/当前检查区、报销右栏计数和报销审批动作接线,在新工作台路由生效后全部删除。
|
||||
- 移动 `MobileApprovalsPage` 中对应的报销详情、证据、检查和报销 fail-closed UI 分支迁入审批适配器后删除。
|
||||
- 通用审批页只保留请假、补卡、采购、差旅等现有实体的通用投影与动作,不保留休眠报销路径、隐藏兜底或复制文案“以防万一”。
|
||||
- 最终结构检查必须证明通用审批页不再导入报销证据组件、不再包含报销检查文案,也不再根据 `entityType === "reimbursement"` 渲染业务内容。该检查与壳层禁用 API/权限/模式判断的检查必须实现于 `scripts/qa/check-ui-design-rules.mjs`,由部署 preflight 的 `--strict` 门禁执行,不得仅存在于单测。
|
||||
- 任务类型分派层可以识别 `reimbursement` 并选择适配器;它只负责路由,不得重新实现报销摘要或决策逻辑。
|
||||
|
||||
### R4 信息必须按审批人决策需要收敛
|
||||
|
||||
1. 顶部只显示报销单号、状态、报销人、日期、合计;事由作为紧接标题的正文展示。不得再用六张字段卡重复下面已经出现的信息,也不显示“业务类型=报销”等自明字段。
|
||||
2. 主列直接进入费用明细,随后进入独立的“票据与附件”区。PC 明细使用紧凑表格,移动明细使用卡片;表内只呈现票据数量/状态,不嵌套票据卡片或文件操作。发票与补充附件在证据区内分组,避免把两类业务语义合并成同一种数据。
|
||||
3. 正常项不解释、不计数。只有存在异常或人工决策需要时才显示提示,并就近说明“对象、原因、影响、下一步”;不得默认平铺“0 条”状态或仅显示无法定位对象的总数。
|
||||
4. 提交时快照与当前票据事实一致时,不并排展示两个“检查摘要”;只有出现异常、快照缺失或时间语义差异时才显示必要内容。
|
||||
5. 历史提交缺少预算快照时,以低强调度说明“该次提交未留存预算快照”;它不得使用告警视觉冒充当前业务异常,也不得用实时预算结果补写历史。
|
||||
6. PC 右栏只保留当前节点/状态、是否可决策、异常锚点和审批动作。报销合计如已在顶部清晰可见,不在右栏重复;创建/发起/完成等时间进入处理历史或按需展开,不平铺为决策摘要。
|
||||
7. “退回”和“驳回”的后果说明只在用户展开相应动作或进入确认时出现;默认右栏不常驻解释卡。错误和提交结果继续提供明确反馈,不能为了降噪删除必要恢复信息。
|
||||
- 警告不得只依赖颜色;标题层级、DOM 顺序和读屏顺序必须与视觉顺序一致。
|
||||
|
||||
### R5 编辑态围绕明细完成票据维护与提交恢复
|
||||
|
||||
- `edit` 适配器向同一费用明细与材料组件组注入新增、编辑、删除、上传/选择 PDF 发票、关联、解绑和未归属恢复能力。
|
||||
- 员工从费用明细进入单一“编辑”入口维护该条明细;删除收进编辑弹窗或明确的二次确认,不与编辑并排常驻。票据上传、关联、解绑和未归属恢复统一位于“票据与附件”区;未归属票据直接提供选择明细的恢复入口。
|
||||
- 整单补充附件继续作为独立证据,不伪造与某条明细的结构化关系。
|
||||
- 填写过程中持续计算可解释的提交前检查;汇总区只提示仍有多少可操作问题并提供锚点,具体错误就近落到明细或票据。
|
||||
- 服务端 B2 门禁继续为权威。前端不得因工作台迁移放宽 `ocr_failed`、manual review、金额超票面、缺票或未归属合同。
|
||||
- 发生日期不取发票日期;系统不自动决定业务是否应拆分,页面继续按既有业务事实源提示员工和财务人工复核。
|
||||
|
||||
### R6 审批态保留快照、实时事实与 fail-closed
|
||||
|
||||
- 费用标准/预算使用 workflow 提交快照;票据归属、金额证据和附件使用当前实时报销详情。两者不得互相替代。
|
||||
- 完整报销内容、关键当前检查或提交快照解析未就绪时,通过、退回、驳回、转审和报销离线草稿均不可执行;动作入口除禁用外仍有中文原因。
|
||||
- 审批适配器只注入材料查看及 workflow 决策能力,不注入报销维护能力。明细和票据不常驻“引用到退回意见”;只有展开“退回”动作后,才在意见编辑区提供可选的明细/票据引用清单。
|
||||
- 报销证据继续排除在移动 IndexedDB workflow cache 之外;其他审批类型的离线草稿能力保持不变。
|
||||
- 已处理任务查看当前报销版本时保留时间语义提示;历史意见和提交快照不被当前内容覆盖。
|
||||
|
||||
### R7 路由、权限与回退必须保持稳定
|
||||
|
||||
- 通用任务路由以一次已授权 task detail 查询选择报销或非报销渲染器,并把该查询结果移交所选渲染器继续使用。非参与人不能从分派错误、报销加载状态或材料错误推断无权对象信息。
|
||||
- 报销详情、票据、附件、短时材料 URL 和审批动作继续使用既有后端授权、用途审计和状态机;本任务不以纯前端隐藏替代服务端校验。
|
||||
- PC/移动从待办列表进入、刷新、浏览器返回、深链打开和已处理任务查看均保持可预测;工作台返回目标由入口上下文决定,不形成“返回审批详情 → 又回到同一工作台”的循环。
|
||||
- 页面级加载失败显示重试和安全返回;单个材料加载失败只影响该材料,不清空已加载证据。
|
||||
|
||||
### R8 上线后按国内报销阅读习惯完成 KISS 收敛
|
||||
|
||||
- PC “费用明细”使用一张表,固定核心列为序号、费用类型、发生日期、费用说明、金额、票据;票据列只显示“免票 / N 张 / 未关联 / 需核对”等短状态。正常行不显示解释文案或业务按钮,异常在对应行内用文字与图标共同标识。
|
||||
- PC 表格的审批态和只读态行内操作为 0;编辑态只允许一个“编辑”入口,删除进入编辑弹窗或二次确认。不得恢复“编辑 + 删除 + 引用 + 查看原件”并排动作。
|
||||
- 移动端保持卡片而不复制 PC 表格;每张卡只显示费用类型、日期、金额、说明和票据状态。正常卡无操作说明,编辑态能力从单一入口进入。
|
||||
- “票据与附件”是明细之后的独立区块,内部按“发票”和“补充附件”分组。文件名/文件行本身是唯一查看入口并具有完整可访问名称;不得为每个文件重复渲染“查看原件”“查看附件”按钮。只有异常票据显示就地恢复动作。
|
||||
- 退回引用采用渐进披露:默认页面不显示引用按钮;用户展开“退回”后,意见框附近显示可勾选的异常明细/票据,必要时可展开全部,选择后生成可编辑的引用文本。关闭退回区不改变业务数据。
|
||||
- “查看完整材料”只在确有批量核对价值时保留为区块级次要入口,不与每个文件的查看入口重复;无材料时不显示空动作。
|
||||
- 本轮只重排前端展示与能力入口,不改变发票—明细关系、B2 门禁、快照/实时语义、材料授权审计、workflow 状态机或后端数据合同。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:PC 与移动的 `edit`、`readonly`、`approval` 使用同一展示模型、区块顺序、费用明细/材料语义组件和 capability 契约;PC 可使用右栏 sticky 布局壳,移动可使用卡片 + 底部动作布局壳,不要求同一份 DOM,模式差异只来自适配器注入的数据与能力。
|
||||
- [ ] AC2:PC/移动布局壳中 `useQuery`、`useMutation`、API 调用、角色/权限判断、workflow/报销状态机判断和 `if (mode === ...)` 业务分支均为 0;`scripts/qa/check-ui-design-rules.mjs --strict` 静态扫描并报 error,且该命令作为部署 preflight 执行。
|
||||
- [ ] AC3:`WorkflowTaskDetailPage` 与 `MobileApprovalsPage` 的报销业务渲染分支、六字段摘要、重复检查区和休眠兜底全部删除;通用审批页仍通过非报销回归。
|
||||
- [ ] AC4:报销审批首屏只有一套紧凑报销头;事由、明细、所属票据、附件、异常和历史各有唯一主展示位置,不再出现两套摘要或四处检查计数。
|
||||
- [ ] AC5:无异常时不显示“0 条”检查卡或常驻退回/驳回解释;异常时每条提示可定位到具体明细/票据,包含原因、影响和下一步。快照/实时不一致时才额外显示差异。
|
||||
- [ ] AC6:PC 右栏只含决策所需状态、异常锚点和单一动作区;窄屏/移动动作区不遮挡内容,触控区域不小于 44×44,键盘和读屏顺序正确。
|
||||
- [ ] AC7:员工可在明细上下文完成新增/编辑/删除、PDF 发票添加、关联/解绑、未归属恢复、提交阻断修复和再次提交;服务端 B2 结果仍为最终权威。
|
||||
- [ ] AC8:审批内容或快照失败时所有报销决策 fail-closed;审批适配器没有任何报销维护能力;readonly 适配器没有编辑或审批能力。
|
||||
- [ ] AC9:报销证据不进入 IndexedDB;非报销审批离线草稿基线、PC/移动非报销详情和 workflow 状态机均无回归。
|
||||
- [ ] AC10:参与人/非参与人、`draft/rejected/reviewing`、pending/已处理任务和材料权限矩阵通过;没有新增 schema、migration、flag、seed 或生产数据改写。
|
||||
- [ ] AC11:生产部署后使用公网真实 bundle + 合成 API 完成三模式、失败关闭和离线验收;真实员工重提反馈记录后,父任务才允许归档。
|
||||
- [ ] AC12:PC 审批/只读费用明细为单表且行内业务按钮为 0;编辑态每行至多一个“编辑”入口。表格在 1280px 无横向溢出,键盘与读屏按列顺序读取。
|
||||
- [ ] AC13:移动费用明细保持卡片;PC/移动共享同一明细字段、顺序、异常语义与 capability 合同,不共享同一 DOM。375px 与横屏无内容遮挡或横向溢出。
|
||||
- [ ] AC14:票据和补充附件从明细中拆出并在独立证据区分组;正常文件仅有一个可访问的查看入口,正常明细/票据不出现“引用到退回意见”“查看原件”“查看附件”按钮文案。
|
||||
- [ ] AC15:只有展开退回动作后才出现引用选择;异常对象优先、全部对象按需展开,选择结果进入可编辑意见。B2 未归属恢复、金额异常锚点、材料查看审计和审批 fail-closed 回归通过。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 请假、补卡、采购、差旅改造成“业务对象页 + 审批模式”;它们继续使用通用审批详情。
|
||||
- `/reimbursements/:id/print` 打印/PDF 页面重构。该页面是格式化交付视图,不是交互式工作台;本任务有意保留其独立布局,但不得把它当作编辑/只读/审批的第四套交互实现,也不得向其中复制工作台 mutation。
|
||||
- 自动从发票推断发生日期、自动判断业务拆分正确性或替财务作真实性结论。
|
||||
- 结构化退回原因、统计模型、财务代员工编辑、批量改票或新审批状态。
|
||||
- 修改 workflow 状态机、B2 flag、付款流程、数据库 schema、生产配置或真人单据。
|
||||
- 为当前冗余页面单独发布临时删减版;信息收敛随 B-2b 独立候选一次交付。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 结构边界:`check-ui-design-rules.mjs --strict` 扫描 PC/移动布局壳,禁止 query/mutation/API/角色/状态机/mode 请求分支,并扫描通用审批页报销分支和旧文案为 0;三适配器能力集合另做组件/类型验证。
|
||||
- 纯函数/组件:统一报销头、明细行/材料投影、异常过滤、快照/实时差异、异常锚点和 capability 注入覆盖正常/异常/缺失边界。
|
||||
- PC/移动 Playwright:三模式内容顺序;PC 明细表/移动明细卡;正常行零动作与零解释;独立票据/附件区;文件单一查看入口;异常就近文案;右栏/底部动作;票据抽屉;未归属恢复;B2 阻断后修复并再次提交;退回动作展开后引用;深链/返回。
|
||||
- 权限/状态:participant/nonparticipant × draft/rejected/reviewing;pending/已处理审批;无权 task、跨单材料、无维护能力和并发关联回归。
|
||||
- 离线:直接读取 IndexedDB 禁存字段/哨兵值;报销动作和草稿 fail-closed;非报销审批离线草稿仍可形成和同步。
|
||||
- 后端:原则上不改合同;复跑 B2 门禁、报销详情只读投影、workflow task detail、材料查看审计、票据 bind/unbind/link 和审批动作聚焦测试。
|
||||
- 前端/治理:typecheck、build、UI strict/self-test、route manifest、相关 Playwright、process contract、Trellis governance、敏感内容检查、docs registry(如改事实源)和 `git diff --check`。
|
||||
- 部署:另行批准独立候选;切换前 UI strict;部署后核 `/ready`、bundle marker、公网文案、三模式合成路径、B2 状态和真人单据状态不变。
|
||||
|
||||
## Risks and Deferred Items
|
||||
|
||||
- 大型旧页面拆分容易在中间阶段形成双实现;允许在开发分支短暂并存,但最终候选必须通过旧分支为 0 的结构门禁。
|
||||
- task 路由先取元数据再分派可能产生加载闪烁;设计必须用稳定骨架而不是先渲染通用页再切换。
|
||||
- 打印页继续独立,未来如字段语义漂移可另建“打印投影复用”任务;本轮不扩大交付面。
|
||||
- 神州 ERP 仅作为国内报销信息层级参考;不复制其旧页面状态管理、过小图标按钮、打印专用边框或数据模型。当前系统继续以自身 B2、权限、审计和可访问性合同为准。
|
||||
- 全局错误色 token、部署脚本 `--no-deps`、`.claude` gitignore 和其他治理债不并入本任务。
|
||||
- B-2b 遗留:`.workflow-action-panel` 在 `index.css` 中仍保留 `relative z-30` 层叠修复,但自 `abdd250` 后全仓库无消费者,且该状态已随 `dda625b` 上线。后续独立核验请假、补卡、采购、差旅审批动作面板层叠;确认后只允许二选一:为通用审批页补回该 class,或删除无效样式及 `paymentProcessingProgress.test.js` 的对应结构断言。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-form-shared-component-migration",
|
||||
"name": "reimbursement-form-shared-component-migration",
|
||||
"title": "B-2b 统一报销工作台迁移",
|
||||
"description": "把 PC/移动报销编辑、只读、审批收敛为一个工作台壳和三个能力适配器,并删除通用审批页的报销旧分支。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "client,workflow,spec",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "4b974fd",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-01-reimbursement-one-page-approval-workbench",
|
||||
"relatedFiles": [],
|
||||
"notes": "父任务第二阶段;方案 A 的统一报销工作台已以候选 9fc3b76 部署,生产 build marker 为 9fc3b764382d。上线真人目视反馈触发 KISS 收尾:PC 明细表格、移动明细卡片、票据/附件独立分组、单一文件查看入口和退回动作内渐进引用已完成,实现提交为 4b974fd;typecheck/build、UI strict/self-test、R102/R111/workflow/视觉聚焦回归及治理通过,等待最终复核,尚未 push、部署或触碰真人单据。",
|
||||
"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,131 @@
|
||||
# Design:报销审批一页式工作台
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
父任务采用两阶段迁移,不做全页面一次性重写:
|
||||
|
||||
```text
|
||||
B-2a
|
||||
Workflow task shell
|
||||
+ submission snapshot adapter
|
||||
+ live reimbursement detail query
|
||||
+ shared item/evidence component (review mode)
|
||||
+ approval decision panel
|
||||
|
||||
B-2b
|
||||
Authorized task/reimbursement route dispatch
|
||||
-> edit / readonly / approval adapters
|
||||
-> one stateless reimbursement workspace shell
|
||||
+ same shared item/evidence component
|
||||
+ invoice drawer + submit preflight
|
||||
- generic workflow reimbursement branches
|
||||
- retired legacy item/material duplication
|
||||
```
|
||||
|
||||
共享边界在 B-2a 先选为“明细 + 挂在明细下的票据”;真人目视反馈后,B-2b 把它上升为唯一交互式报销工作台。旧 `ReimbursementDetailPage` 的查询、mutation、权限判断和编辑状态进入 edit adapter,workflow task、审批 mutation 与 fail-closed 进入 approval adapter,只读查询进入 readonly adapter;工作台壳只负责编排已经计算好的展示模型和 capability/action slots。
|
||||
|
||||
## 2. Data Authority Matrix
|
||||
|
||||
| 信息 | 权威来源 | 时间语义 | 展示标签 |
|
||||
| --- | --- | --- | --- |
|
||||
| 报销头、明细、票据归属、附件 | `GET /reimbursements/:id` | 当前事实 | 当前报销内容 |
|
||||
| 票面金额证据/人工核对 | 报销详情返回的 amount evidence | 当前事实 | 当前票据检查 |
|
||||
| 费用标准超标 | `workflowInstance.contextData.standardViolations` | 提交冻结 | 提交时检查 |
|
||||
| 预算提示 | 新增最小 `contextData.budgetCheck` | 提交冻结 | 提交时预算 |
|
||||
| 审批任务/历史 | workflow task/history | 流程历史 | 处理历史 |
|
||||
| 办公电子设备提示 | 明细类型 + 既有固定阈值纯函数 | 当前确定性规则 | 选类提示 |
|
||||
|
||||
不从审批页调用实时 precheck 替代快照。历史 context 缺 `budgetCheck` 时返回 null 并明确显示缺失,不做当前预算回填。
|
||||
|
||||
## 3. Shared Component Contract
|
||||
|
||||
建议建立面向展示模型的共享组件,而不是传整个 workflow 对象:
|
||||
|
||||
```ts
|
||||
type ReimbursementItemEvidenceModel = {
|
||||
item: ReimbursementItem;
|
||||
invoices: ReimbursementInvoiceEvidence[];
|
||||
snapshotChecks: SubmissionCheck[];
|
||||
liveChecks: LiveEvidenceCheck[];
|
||||
};
|
||||
|
||||
type ReimbursementItemEvidenceListProps = {
|
||||
models: ReimbursementItemEvidenceModel[];
|
||||
unlinkedInvoices: ReimbursementInvoiceEvidence[];
|
||||
onOpenInvoice: (invoiceId: string) => void;
|
||||
onReferenceReturn?: (reference: ReturnReference) => void;
|
||||
editCapabilities?: ReimbursementEvidenceEditCapabilities;
|
||||
};
|
||||
```
|
||||
|
||||
分组、文案和检查映射由纯函数完成。B-2a 页面容器暂时负责查询、mutation、导航和动作面板;B-2b 将这些职责迁入三种适配器,唯一工作台壳不得拥有 query/mutation/角色/状态机判断。`editCapabilities` 在 B-2a 不启用,B-2b 只由 edit adapter 接入,避免审批/只读视角出现维护分支。
|
||||
|
||||
## 4. Responsive Page Structure
|
||||
|
||||
### PC
|
||||
|
||||
```text
|
||||
审批详情:报销单号 / 状态 / 当前节点
|
||||
┌──────────────────────────────────────┬────────────────────┐
|
||||
│ 报销摘要与事由 │ 合计 │
|
||||
│ 明细 1 │ 当前节点/状态 │
|
||||
│ ├─ 费用/日期/金额/说明 │ 合并检查摘要 │
|
||||
│ └─ 发票 A / 发票 B / 原件入口 │ 审批意见 │
|
||||
│ 明细 2 ... │ [通过] │
|
||||
│ 尚未对应明细的票据 │ [退回][驳回][更多] │
|
||||
│ 补充附件 │ (sticky) │
|
||||
│ 处理历史 │ │
|
||||
└──────────────────────────────────────┴────────────────────┘
|
||||
```
|
||||
|
||||
右栏只放状态摘要和决策动作,不承载完整审批流。提交快照与当前票据检查默认合并为一块,只有二者不一致时在同一块内显示差异。动作使用一个主按钮、少量横向紧凑次按钮和“更多”,禁止竖向动作卡片。PC 主列底部不复制按钮;低于两栏断点时,同一个动作面板才进入内容底部。
|
||||
|
||||
### Mobile
|
||||
|
||||
```text
|
||||
状态/单号
|
||||
报销摘要
|
||||
明细 1 → 所属票据
|
||||
明细 2 → 所属票据
|
||||
未归属票据
|
||||
补充附件
|
||||
提交时检查
|
||||
当前票据检查
|
||||
处理历史
|
||||
底部:[通过] [更多]
|
||||
```
|
||||
|
||||
移动“更多”承载退回、驳回和转审;理由输入与确认保持语义化焦点管理。内容底部预留 safe-area 和固定操作栏高度。
|
||||
|
||||
审批页与填写页的动作规则有意不同:审批是只读核验后的一次决策,保持单一固定入口;填写是长表单,B-2b 可基于填写动线另行确认是否需要表单末尾提交入口。这不改变共享业务内容组件。
|
||||
|
||||
## 5. Snapshot Contract
|
||||
|
||||
报销提交已经形成 `budgetCheck`,B-2a 只把其最小、无敏感字段的值同时写入 workflow `context_data`:预算标识/代码、控制模式、使用比例、是否达到阈值、是否超预算。workflow task detail 对外投影显式字段,不原样返回任意 context JSON。
|
||||
|
||||
`standardViolations` 继续沿用当前快照。两者必须和创建 workflow instance 同一事务写入;退回、规则变更或预算后续变化不改写旧实例。
|
||||
|
||||
## 6. Live Detail Loading and Permissions
|
||||
|
||||
B-2a 客户端在 workflow task 已授权后读取现有报销详情 API,复用既有只读投影、材料查看和审计合同,不复制 server select。该双查询把在线证据与 workflow 离线摘要分开,但不能只依赖 DTO 当前形状保证安全;`cacheWorkflowTaskDetail` 仍必须改为显式允许字段投影,防止未来 workflow DTO 扩展后把证据自动落盘。
|
||||
|
||||
若 workflow task 可见但报销详情因既有对象权限失败,页面 fail closed 并记录为权限合同缺口;本任务不得只在前端绕过。实施前用角色/委托矩阵证明当前审批人、财务和已处理查看者的读取基线,发现缺口时只设计“任务关联对象只读”最小授权,不授予 `expense.view_all` 写能力。
|
||||
|
||||
## 7. Return Reference Without Schema Change
|
||||
|
||||
组件生成无 UUID 的可读引用,例如“住宿费|2026-08-25|¥680.00”或“发票|2026-08-25|¥680.00”。点击后把引用加入审批页本地待退回清单,退回确认时合成为普通 comment 文本。删除引用只改本地状态;服务端接口和数据库不变。
|
||||
|
||||
## 8. Compatibility and Migration
|
||||
|
||||
- B-2a 保留完整原始表单入口作为降级路径;核心核验不得依赖它。
|
||||
- B-2a 不删除旧填写组件,只新增共享组件的 review/readonly 能力。
|
||||
- B-2b 接入三种适配器后删除被替代布局,并删除 PC/移动通用审批页的报销业务分支;不长期维护双实现或运行时 feature flag。
|
||||
- `/reimbursements/:id/print` 保持专用打印布局,不纳入三模式交互壳,只做不误伤回归。
|
||||
- 旧 workflow 无预算快照兼容为 null。
|
||||
- 当前已处理任务展示实时内容时加版本提示,避免历史误读。
|
||||
|
||||
## 9. UI/UX Review Notes
|
||||
|
||||
`ui-ux-pro-max` 的自动 design-system 搜索把本页误配为 FAQ/夸张极简风,未采用该视觉结果;本任务保留项目现有 OA 设计系统,仅采用其适用的通用规则:错误就近、恢复动作明确、语义 HTML、焦点管理、移动触控区域不小于 44px、警告不只靠颜色、sticky 区不遮挡内容。
|
||||
|
||||
布局本身不构成新视觉裁决,而是落实既有 `docs/产品设计规则.md:73` 与归档统一页面评审:证据居中、历史靠后、PC 右侧或移动底部单一动作面板、多个审批动作不得竖向铺卡片。
|
||||
@@ -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,25 @@
|
||||
# Implementation Plan:报销审批一页式工作台
|
||||
|
||||
## Delivery Map
|
||||
|
||||
1. 先实施并独立部署 B-2a。
|
||||
2. 收集至少一次真实报销审批反馈。
|
||||
3. 回填 B-2b 详细设计后独立实施和部署。
|
||||
4. B-2b 删除重复布局并通过真实填写/审批闭环后,父任务才可完成归档。
|
||||
|
||||
## Parent Integration Gates
|
||||
|
||||
- [ ] B-2a 的共享组件 API 不依赖 workflow 页面对象。
|
||||
- [ ] 快照/实时数据标签和缺失兼容在 B-2a 固化。
|
||||
- [ ] PC 使用右侧单一动作面板且主列底部无重复动作;完整审批流/历史位于主列证据之后。
|
||||
- [ ] 移动 workflow 缓存使用显式允许字段投影,报销证据未落盘且离线报销动作全部阻断。
|
||||
- [ ] 真人反馈没有发现审批人仍需跳转取得核心内容。
|
||||
- [ ] B-2b 采用同一组件并删除被替代布局。
|
||||
- [ ] 两轮均有独立 commit、候选 SHA、回滚点和公网验证记录。
|
||||
- [ ] 更新 frontend spec,替换旧“只提供业务详情出口”的临时合同。
|
||||
|
||||
## Validation
|
||||
|
||||
- 每个子任务分别运行其 PRD Test Plan。
|
||||
- 父任务收口运行 `task.py validate`(父与两个子任务)、Trellis governance、process contract、docs registry(如更新 docs)和 `git diff --check`。
|
||||
- 面向用户可测试的改动只有在各自部署和公网检查通过后才能通知测试。
|
||||
@@ -0,0 +1,116 @@
|
||||
# 报销审批一页式工作台
|
||||
|
||||
## Goal
|
||||
|
||||
让主管和财务在同一审批页面内完成“看报销内容—核明细与票据—识别风险—填写意见—作出审批决定”,不再把“查看报销单”外链作为取得核心报销内容的必经入口;同时建立可复用的“报销明细 + 所属票据”业务内容组件,并承诺在后续填写态迁移后消除历史布局分叉。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- PC `WorkflowTaskDetailPage.tsx:302-305` 的报销投影只有报销单号、金额、提交人和报销人,随后提供“查看报销单”外链;移动审批详情同样只有摘要和“查看完整报销单”。
|
||||
- 完整报销详情已有实时明细、发票、附件、材料预览、票面金额证据和处理进度,但其布局以报销人填写/旧只读查看为中心,不按审批决策顺序组织。
|
||||
- 审批页的 `workflowInstance.contextData.standardViolations` 是提交时冻结快照;报销详情、票据关联及金额证据来自当前报销实时读取。两者语义不同,不能互相替代。
|
||||
- 报销提交事务已经计算 `budgetCheck`,但当前只写入提交响应,没有进入 workflow 快照;新提交若要让审批人看到提交时预算事实,需要补充 workflow context 快照合同。
|
||||
- 非参与人只读投影任务已明确:审批/财务详情不主动请求参与人专用 precheck,行内费用标准提示由本任务恢复;不得为恢复提示而放宽 precheck 对象权限。
|
||||
- 移动审批当前允许缓存任务摘要和离线保存通过/驳回草稿,但缓存不含完整报销明细与票据证据。
|
||||
- 现有前端 spec 的“审批页只提供业务详情出口、不复制报销内容”是旧阶段的临时可达性合同,本任务批准实施后应被新的共享内容组件合同取代,而不是继续并存。
|
||||
- 现有 `docs/产品设计规则.md:73` 已规定“证据居中、审批/处理历史靠后、当前动作面板固定在 PC 右侧或移动端底部”;归档统一设计评审还规定审批动作不得竖向铺成多个大卡片。当前报销审批页只有四字段和外链,未满足既有证据结构;本任务是回归既有规则,不另创页面范式。
|
||||
|
||||
## Delivery Strategy
|
||||
|
||||
采用“先审批、后填写迁移”的 B 路线,并拆成两次可独立验收的交付:
|
||||
|
||||
1. **B-2a 报销审批内容工作台**:建立共享的“明细 + 所属票据”只读/核验组件,先接入 PC 和移动审批页;审批人无需跳转即可取得决策所需内容。
|
||||
2. **B-2b 统一报销工作台迁移**:根据 B-2a 真人目视反馈,把 PC/移动编辑、只读、审批收敛为一个纯布局工作台壳和三个能力适配器,并完成票据抽屉、提交前检查、信息降噪及旧页面分支删除。
|
||||
|
||||
B-2a 完成时允许两套交互式页面壳暂时存在;B-2b 是本父任务的必交子任务,不得以“以后优化”关闭父任务。父任务只有在三种模式使用唯一报销工作台、通用审批页报销分支和旧重复布局均被删除后才能归档为完成。专用打印/PDF 页面是有意排除的格式化交付视图,不计作第四种交互模式。
|
||||
|
||||
## Scope
|
||||
|
||||
- 父任务负责两阶段交付合同、唯一工作台边界、快照/实时数据口径、跨子任务验收和最终旧布局收口。
|
||||
- B-2a 负责 PC/移动审批一页式内容工作台与共享组件 review/readonly 能力。
|
||||
- B-2b 负责唯一工作台壳、edit/readonly/approval 适配器、票据抽屉、提交前检查、通用审批页报销分支删除和信息收敛。
|
||||
- 两个子任务分别形成候选、部署和回滚点;父任务不直接承载业务代码实施。
|
||||
|
||||
## Product Requirements
|
||||
|
||||
### R1 审批决策不再依赖二次跳转
|
||||
|
||||
- 报销审批详情直接展示报销事由、报销人/提交人、部门、日期、金额、明细、每条明细所属票据、未归属票据、补充附件、核验提示和审批历史。
|
||||
- “查看报销单”降级为“打开完整原始表单/打印视图”的次要入口,用于少见的完整字段追溯,不再承担核心内容可达性。
|
||||
- 非报销审批类型保持当前各自投影,本父任务不借机重构请假、采购、差旅或考勤审批。
|
||||
|
||||
### R2 共享内容组件必须收敛为唯一工作台
|
||||
|
||||
- B-2a 建立的共享件以业务数据和交互能力为边界,不依赖 `WorkflowTaskDetailPage` 或审批状态;至少覆盖“明细 + 关联票据 + 行内核验提示”。
|
||||
- PC 与移动审批使用同一语义组件和字段顺序,只允许响应式布局和操作容器不同。
|
||||
- B-2b 必须让 PC/移动编辑、只读、审批采用同一纯布局工作台壳和同一业务内容顺序;查询、状态、mutation、权限和 fail-closed 全部归各模式适配器。
|
||||
- 工作台壳不得根据 mode、角色、权限或业务状态发请求或推导能力;编辑、删除、关联、审批等动作只通过显式 capability/action slot 注入。
|
||||
- B-2b 完成后,不得继续保留“旧填写态、新审批态、旧只读态”三套并行布局,也不得在 PC/移动通用审批页保留休眠报销业务分支。
|
||||
|
||||
### R3 快照事实与实时事实分层展示
|
||||
|
||||
- **提交时快照**:费用标准超标、预算提示及提交时形成的规则/风险结论。审批页面必须标注“提交时检查”,不得用当前 precheck 结果覆盖。
|
||||
- **当前实时事实**:报销头、明细、票据归属、票面金额证据、补充附件及当前可见的材料状态。审批页面必须标注“当前报销内容/当前票据检查”。
|
||||
- 退回补票、重绑或重提后,当前内容可以变化;历史审批任务查看到当前版本时必须给出版本语义,不得把当前内容伪装为旧任务处理当时的原始快照。
|
||||
- 历史 workflow 没有预算快照时显示“该次提交未记录预算快照”,不得回填或冒充当前预算结果。
|
||||
|
||||
### R4 恢复审批人需要的行内提示
|
||||
|
||||
- `standardViolations` 按 `itemId` 回到对应明细旁显示,并明确控制模式和审批影响。
|
||||
- 办公用品达到既有金额阈值时继续显示“通常应选择办公电子设备”的非阻断提示;不得把该阈值描述成会计固定资产标准。
|
||||
- 票据未归属、需票明细缺票、票面金额需人工核对或金额不可用等当前事实,必须就近显示到具体明细或票据;系统不校验日期粒度或明细拆分正确性,页面不得暗示会自动判断。
|
||||
|
||||
### R5 退回定位先解决可用性,不扩大数据模型
|
||||
|
||||
- B-2a 允许审批人从具体明细或票据发起“引用到退回意见”,生成员工可读的对象描述并带入现有自由文本意见。
|
||||
- 本轮不新增退回原因分类表、结构化 reason schema、migration 或新的 workflow 状态;最终仍通过现有 comment 字段存储自由文本。
|
||||
- 若后续真人使用证明需要统计型退回原因,再另建数据模型任务,不在 B-2a 顺手扩展。
|
||||
|
||||
### R6 审批动作与证据可见性绑定
|
||||
|
||||
- PC 宽屏右侧 sticky 区只展示状态摘要、报销合计、合并后的检查摘要、审批意见与决策动作;完整审批流和处理历史放在主列证据之后。
|
||||
- 提交时快照与当前票据事实默认合成一个短检查摘要,只有两者不一致时额外标出差异及时间语义,避免在窄栏并列两个难以区分的“检查摘要”。
|
||||
- PC 只有右侧一个审批动作面板,不在主列底部复制第二套按钮;窄屏降为单列时,同一动作面板才转到底部。
|
||||
- `通过`为唯一主按钮,`退回`、`驳回`为紧凑次操作,`转审`默认进入“更多”;禁止把多个处理动作竖向铺成大卡片。
|
||||
- 移动端按同一内容顺序单列展示,底部只保留一个主操作,退回、驳回和转审进入次级操作/确认流程;固定操作区不得遮挡内容。
|
||||
- 报销内容或关键核验数据加载失败时,不得仍允许审批人基于四字段摘要直接通过。
|
||||
|
||||
### R7 报销审批离线必须保护决策和证据
|
||||
|
||||
- 报销审批必须联网并成功加载完整明细、票据和关键检查后,才能通过、退回、驳回或保存审批草稿;离线摘要不得成为报销决策依据。
|
||||
- `cacheWorkflowTaskDetail` 必须使用显式脱敏/允许字段投影,排除报销明细、票据证据、附件元数据和材料访问数据;不能依赖“当前 workflow DTO 尚未包含这些字段”作为安全边界。
|
||||
- 其他审批类型维持现有离线能力,本任务不全局关闭移动审批离线草稿。
|
||||
- 完整报销内容只保留在在线查询生命周期内,不为 B-2a 扩展 IndexedDB workflow cache;如未来需要离线报销审批,必须另行规划敏感数据缓存、过期、清除和设备边界。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] B-2a 和 B-2b 作为两个独立子任务存在,前者可单独上线产生完整业务价值,后者是父任务关闭前的强制迁移项。
|
||||
- [ ] PC 与移动报销审批页无需跳转即可看到完整决策内容,并能打开票据原件和补充附件。
|
||||
- [ ] 每条明细与所属票据在同一区域展示;未归属票据和行内提示可直接定位,不依赖页面顶部汇总猜测对象。
|
||||
- [ ] 费用标准与预算使用提交时快照,票据关联与金额证据使用当前实时事实;页面文字明确区分两者。
|
||||
- [ ] 历史缺少预算快照时不做实时替代;退回后内容变化时不把当前版本冒充旧提交快照。
|
||||
- [ ] 非参与人只读权限、材料查看审计、审批状态机和 B2 票据门禁不因页面整合而放宽。
|
||||
- [ ] B-2a 恢复明细行内费用标准提示和既有办公电子设备提示。
|
||||
- [ ] 退回可以引用具体明细/票据到现有自由文本意见;本父任务不新增结构化退回原因数据模型。
|
||||
- [ ] PC 只有右侧单一审批动作面板,完整流程/历史位于主列靠后;窄屏和移动才把同一动作面板转到底部,多个动作不竖向铺卡片。
|
||||
- [ ] 报销审批离线时不能形成任何审批动作或草稿,且离线缓存中不存在明细、票据、附件或材料访问证据;其他审批类型离线合同不变。
|
||||
- [ ] B-2b 完成后,编辑、审批和只读共享同一无业务状态的工作台壳;通用 PC/移动审批页报销分支和旧重复布局为 0。
|
||||
- [ ] 两次交付分别通过各自的前后端、PC/移动、无障碍、文案、权限和部署验证;生产部署与真人数据写入仍需单独批准。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 把已上线的 B-2a 与 B-2b 合并成一次候选或一次回滚点;B-2b 仍必须独立规划、验证和部署。
|
||||
- 请假、采购、差旅申请、考勤等其他业务的审批内容重构。
|
||||
- `/reimbursements/:id/print` 打印/PDF 视图重构;它保持独立纸面布局,但不得承担交互式工作台职责。
|
||||
- 结构化退回原因 schema、统计报表、数据库 migration。
|
||||
- 修改审批角色、审批规则、状态机、B2 flag 或付款流程。
|
||||
- 自动判断明细日期粒度、业务是否应拆分或费用是否真实合理。
|
||||
- 操作或读取三张真人 rejected 报销单;其业务处理与本任务并行推进。
|
||||
- 推送、部署、生产配置或生产数据写入。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 分别执行 B-2a、B-2b PRD 的前后端、PC/移动、权限与部署验证。
|
||||
- 父任务收口时验证共享组件确实被审批态、填写态和只读态复用,并检查旧重复布局已删除或退役。
|
||||
- 运行父子三任务 `task.py validate`、process contract、Trellis governance、敏感内容检查和 `git diff --check`。
|
||||
- 如同步业务事实源,再运行 docs registry;未经部署和公网检查,不通知用户测试。
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"id": "reimbursement-one-page-approval-workbench",
|
||||
"name": "reimbursement-one-page-approval-workbench",
|
||||
"title": "报销审批一页式工作台",
|
||||
"description": "拆分交付报销审批一页式内容工作台与填写态共享组件迁移,统一明细、票据和核验信息结构。",
|
||||
"status": "planning",
|
||||
"dev_type": null,
|
||||
"scope": "trellis,client,server,spec",
|
||||
"package": null,
|
||||
"priority": "P1",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [
|
||||
"09-01-reimbursement-approval-content-workbench",
|
||||
"09-01-reimbursement-form-shared-component-migration",
|
||||
"09-02-reimbursement-readonly-content-consistency"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "采用 B 路线:B-2a 先审批、B-2b 后迁移填写态;父任务仅在旧重复布局收口后完成。审批页回归既有右侧单一动作面板规则;移动报销审批要求联网且证据不进入离线缓存。规划阶段不编码、不部署、不处理真人单据。",
|
||||
"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 @@
|
||||
# Design:报销只读内容一致性与审批上下文收敛
|
||||
|
||||
## 1. Information Classification Contract
|
||||
|
||||
采用来源驱动的三分法,不按页面、区块位置或当前用户分类:
|
||||
|
||||
```text
|
||||
ReimbursementDeclaredContent(报销人申报)
|
||||
├─ header / description
|
||||
├─ expense items / authoritative total
|
||||
├─ invoice and attachment facts
|
||||
└─ declared project / travel association
|
||||
|
||||
ReimbursementProcessingContext(审批人或系统形成)
|
||||
├─ expense-policy / budget snapshot
|
||||
├─ invoice amount-evidence state / B2 result
|
||||
├─ processing progress
|
||||
├─ workflow history
|
||||
└─ decision actions
|
||||
|
||||
ReimbursementArchiveMetadata(打印动作形成)
|
||||
└─ QR code / printedAt / printedBy
|
||||
```
|
||||
|
||||
字段是否出现在打印页不决定类别。打印页可以组合正文、处理上下文和归档元数据;三模式一致性只约束 `ReimbursementDeclaredContent`。
|
||||
|
||||
## 2. Workspace Model Boundary
|
||||
|
||||
保持 B-2b 的壳/适配器边界,不建立第二套页面模型。共享工作台模型增加一个明确的权威明细汇总投影,PC/移动语义组件都消费同一值:
|
||||
|
||||
```ts
|
||||
type ReimbursementAmountSummary = {
|
||||
authoritativeTotalAmount: string | null;
|
||||
integrity: "ready" | "mismatch" | "unavailable";
|
||||
};
|
||||
```
|
||||
|
||||
`authoritativeTotalAmount` 只来自 reimbursement detail 的服务端 `totalAmount`。PC `tfoot`、移动汇总行和页头均格式化同一字段;任何页面或渲染组件不得用 `items.reduce()` 生成展示总额。
|
||||
|
||||
完整性判断只在共享模型构建边界执行一次。明细金额以严格十进制字符串解析为整数分后求和,仅生成 `integrity`,派生和不得暴露为第二个业务总额或参与提交。无法安全解析时使用 `unavailable`,不伪造 mismatch;服务端仍是业务权威。
|
||||
|
||||
## 3. Integrity Failure Behavior
|
||||
|
||||
- `ready`:只显示普通合计,不显示说明。
|
||||
- `mismatch`:在明细合计附近显示“报销总额与明细汇总不一致,请联系管理员”,并作为 blocking issue 注入审批 readiness,使通过、退回、驳回、转审全部 fail-closed。
|
||||
- `unavailable`:保留服务端权威总额展示,同时说明“明细金额无法完整核对,请联系管理员”,并按既有“关键内容未就绪不得审批”合同 fail-closed;它不是 mismatch,也不得显示虚构差额。
|
||||
|
||||
异常不提供“修改总额”入口,不改变 B2 或服务端 recompute 合同。
|
||||
|
||||
## 4. Desktop and Mobile Amount Presentation
|
||||
|
||||
PC 在现有语义表格中使用真正的 `<tfoot>`,列跨度随 edit 操作列存在与否调整;标签为“明细合计”,金额单元格保持右对齐、tabular numerals,并通过 scope/可访问文本说明这是服务端权威总额。
|
||||
|
||||
移动端不模拟表格,在明细卡片组末尾渲染同一 `ReimbursementAmountSummary` 的汇总行。它与页头金额允许重复,因为两者分别服务“整单识别”和“逐条核对后的闭合”,但值与可访问名称必须一致。
|
||||
|
||||
## 5. Approval History Placement
|
||||
|
||||
PC approval 由适配器组合右栏内容,不让共享壳判断模式:
|
||||
|
||||
```text
|
||||
BusinessDetailAside
|
||||
├─ sticky DecisionActionPanel
|
||||
└─ WorkflowHistoryPanel
|
||||
```
|
||||
|
||||
审批动作卡继续 sticky;历史卡位于动作之后但不并入 sticky 容器,避免 6 条历史或长意见改变动作可达性。PC 主列不再传 `timeline`。移动适配器继续把相同完整历史传入正文末尾,底部动作保持独立安全区。
|
||||
|
||||
不新增历史摘要,不截断最近一条意见,不保留主列隐藏副本。历史空状态仍可见,但不使用告警视觉。
|
||||
|
||||
## 6. Progress Versus History
|
||||
|
||||
`ProcessingProgress` 和 workflow timeline 不合并:
|
||||
|
||||
- 普通详情的进度模型面向申请人,包含未来阶段、当前阶段和唯一付款批次入口。
|
||||
- 审批 timeline 面向处理人,只呈现已发生 workflow 事件及意见。
|
||||
|
||||
共享的是格式化与动作/状态语义,不强求同一 DOM。测试使用同一 workflow fixture 对照已发生事件,证明名称、人员、时间和结果不矛盾;未来阶段只允许出现在进度模型。
|
||||
|
||||
## 7. Print and Compatibility Boundary
|
||||
|
||||
打印页继续作为组合归档视图:明细合计仍直接显示服务端 `totalAmount`,审批记录和归档元数据保持现状。本任务不把打印页迁入工作台,也不删除它的审批记录。
|
||||
|
||||
不改 API、schema、migration 或状态机。现有 B2、材料查看审计、离线缓存白名单和 capability 注入全部保持。
|
||||
|
||||
## 8. UI/UX Constraints
|
||||
|
||||
沿用 `ui-ux-pro-max` 适用规则:使用语义表格元素;长历史意见可自然换行且不得溢出;异步内容预留稳定位置;金额采用本地化格式;异常不能只靠颜色。PC 1280px 无页面级横向滚动,移动 375px/横屏无内容遮挡,触控目标不小于 44×44。
|
||||
|
||||
## 9. Rollout Boundary
|
||||
|
||||
本设计经最新规划评审批准后可以进入实施,不再等待父任务 AC11。commit、push、部署继续使用独立批准门。部署后由 AC11 检查三项假设:财务是否认可明细区合计、右栏历史是否更利于决策、申请人是否混淆进度与审批历史;反馈若推翻方案,再以新事实开启修订,不现场扩 scope。
|
||||
@@ -0,0 +1 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Implementation Plan:报销只读内容一致性与审批上下文收敛
|
||||
|
||||
## Entry Gate
|
||||
|
||||
- [x] 用户已批准“先改进、后由 AC11 验收”的最新规划摘要,并运行 `task.py start`。
|
||||
- [x] 实施前加载 `trellis-before-dev`,读取 frontend、reimbursement、workflow、privacy 和 UI QA 规约。
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. [x] 在共享工作台模型增加 `ReimbursementAmountSummary`,以服务端 `totalAmount` 作为唯一展示总额;用严格十进制分值解析生成仅用于完整性判断的状态。
|
||||
2. [x] 扩展共享费用明细组件:PC 添加语义 `tfoot`,移动添加等价汇总行;edit/readonly/approval 均由同一模型注入。
|
||||
3. [x] 把 mismatch/金额不可解析转为各自准确、可定位的 blocking issue,并接入审批 readiness 二次拦截;不提供手工改总额入口,不改变服务端合同。
|
||||
4. [x] PC approval 将完整历史从主列移到右栏动作卡之后,动作卡保持独立 sticky;移动历史位置不变。
|
||||
5. [x] 明确正文/上下文负向结构证据:三模式正文不得各自汇总金额;打印元数据不得进入工作台;审批上下文不得进入 edit/readonly 正文一致性断言。
|
||||
6. [x] 更新 `docs/产品设计规则.md` 的历史布局规则:短审批流程可在 PC 右栏动作之后展示完整历史且主列不重复,移动仍在正文末尾;保留长流程使用主列的通用边界。
|
||||
7. [x] 更新相关 PC/移动三模式、打印、权限、B2、离线和非报销回归;不修改真人单据。
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] 纯函数:权威总额透传;`0`、小数、千位、非法金额、正常一致、mismatch、unavailable;使用整数分比较,无浮点误差。
|
||||
- [x] 组件:PC `tfoot` 的 colspan 随 edit 操作列正确;移动汇总语义等价;三模式显示值一致;正常状态无额外说明。
|
||||
- [x] 审批:mismatch 四动作 fail-closed;右栏动作在 6 条历史和长意见下保持可达;主列 timeline 为 0;历史只出现一次。
|
||||
- [x] 处理语义:普通详情仍有 `ProcessingProgress` 与唯一付款批次入口;审批 timeline 不出现未来阶段;同一已发生事件的动作、人员、时间、结果一致。
|
||||
- [x] 回归:edit 能维护、readonly 无维护、approval 无报销维护、B2 未归属恢复、打印合计/审批记录/二维码/打印元数据、移动离线白名单和非报销审批。
|
||||
- [x] `cd client && npm run typecheck && npm run build`。
|
||||
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。
|
||||
- [x] 相关 Playwright:1280px、375px、横屏、三模式、长历史、完整性故障、打印回归。
|
||||
- [x] `python3 ./.trellis/scripts/task.py validate 09-02-reimbursement-readonly-content-consistency`。
|
||||
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、敏感内容检查、docs registry、`git diff --check`。
|
||||
|
||||
## Review and Delivery Boundary
|
||||
|
||||
- [x] 涉及 workflow 决策 readiness,实施后执行只读 review;inline 模式不派发 implement/check 子代理。
|
||||
- [x] 无 schema、migration、flag、seed 或生产数据写入。
|
||||
- [ ] commit、push、部署分别等待明确批准;部署后仅用公网真实 bundle + 合成 API 和生产只读事实验收。
|
||||
- [ ] 本子任务部署不替代父任务 AC11 真人反馈;AC11 未完成时父任务继续保持开放。
|
||||
|
||||
## Rollback
|
||||
|
||||
- 模型或 fail-closed 回归失败:停止在本地,恢复到本任务前候选,不引入 feature flag 双轨。
|
||||
- PC 历史布局影响动作可达:保留移动行为,PC 回到主列 timeline,再复核右栏结构。
|
||||
- 公网验收失败:回滚应用候选,不处理业务数据。
|
||||
|
||||
## Local Verification Evidence
|
||||
|
||||
- `npm run build`:PASS(包含 typecheck 与 Vite production build)。
|
||||
- `workflow-action-confirm-ui.spec.ts`:7/7 PASS,覆盖正常负向断言、两类完整性故障四动作 fail-closed、PC 六条长历史和移动 375px/横屏安全区。
|
||||
- `r102-reimbursement-main-flow.spec.ts`:6/6 PASS;`r111-invoice-reimbursement-guard.spec.ts`:8/8 PASS。
|
||||
- 共享模型/结构合同:13/13 PASS;移动报销离线缓存专项以 Chromium iPhone 视口 1/1 PASS。WebKit 浏览器二进制未安装,因此原 WebKit 命令为环境 PAUSED,不改测试口径。
|
||||
- UI strict/self-test、task validate、process contract、Trellis governance、敏感内容、docs registry(保留既有 22 个未登记文件 warning)和 `git diff --check` 均通过。
|
||||
- `bash scripts/check-minimal.sh` 的本轮相关检查通过;全量后端为 1364/1368,4 条失败均不在本任务改动面:3 条因当前 Python 缺少固定 BIFF8 依赖,1 条是 HEAD 已存在的通用审批页结构断言漂移。本任务不修改 server、付款导出或通用审批页。
|
||||
@@ -0,0 +1,98 @@
|
||||
# 报销只读内容一致性与审批上下文收敛
|
||||
|
||||
## Goal
|
||||
|
||||
让同一张报销单在 PC/移动、编辑/只读/审批和不同授权用户眼中保持同一份报销正文语义,同时把审批动作、审批历史和员工处理进度明确留在各自上下文中。补齐费用明细后的权威合计,避免审批人依赖页头回看或自行心算;在不复制内容、不引入第二业务金额来源的前提下保持 KISS。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- B-2b 已部署统一工作台并通过公网真实 bundle + 合成 API 验收;旧报销审批分支已经删除,当前父任务仍等待 AC11 真人重提反馈。
|
||||
- PC/移动工作台头部均显示服务端 `detail.totalAmount`,但共享费用明细组件没有明细区合计;打印页已有“合计”行,也直接显示同一个 `detail.totalAmount`。
|
||||
- 服务端 `recomputeTotalAmount` 在明细变化和提交链路按明细聚合重写 `reimbursement_forms.total_amount`;`docs/报销发票附件规则.md` 明确 PC、手机和打印页只展示服务端权威总额,不提供手工填写。
|
||||
- 当前审批适配器把完整 `timeline` 注入主列末尾;PC 右栏只放审批决定,移动端历史也位于内容末尾。
|
||||
- 普通报销详情使用 `ProcessingProgress` 回答“我的报销/付款走到哪一步”;审批任务使用 workflow timeline 回答“谁执行了什么动作、留下什么意见”。二者服务不同问题,不视为同一组件的视觉分叉。
|
||||
- 当前打印页包含三类来源不同的信息:报销人申报的报销头/事由/明细/合计/票据,审批人或系统形成的审批记录,以及打印动作形成的二维码/打印人/打印时间。页面位置不能替代信息来源成为分类依据。
|
||||
|
||||
## Scope
|
||||
|
||||
- 定义并验证报销正文、处理上下文和归档元数据的来源分类合同。
|
||||
- 在统一工作台的 PC/移动明细区补充服务端权威合计,并建立仅用于完整性判断的共享状态。
|
||||
- 将 PC 报销审批完整历史移入右栏动作之后且只保留一份;移动历史位置不变。
|
||||
- 固化 `ProcessingProgress` 与 workflow timeline 的职责分工和一致性回归。
|
||||
- 更新与上述行为直接冲突的产品体验事实和自动化验收;不改业务数据合同。
|
||||
|
||||
## Product Requirements
|
||||
|
||||
### R1 按信息来源划分正文、处理上下文和归档元数据
|
||||
|
||||
- 业务正文是报销人申报的内容:报销头、事由、费用明细、权威明细合计、票据与附件,以及由报销人申报的关联项目/差旅信息。PC/移动的 edit、readonly、approval 必须消费同一正文模型并逐字段一致。
|
||||
- 处理上下文是审批人或系统对本次申报形成的判断与记录:提交时快照、费用标准/预算判断、票据金额证据状态、B2 检查、处理进度、审批历史和审批动作。上下文可以按模式和权限出现或省略;省略不构成正文不一致,但同一事实在不同模式出现时语义不得冲突。
|
||||
- 归档元数据是打印动作形成的信息:二维码、打印时间和打印人。它只属于打印页,不进入交互工作台正文。
|
||||
- 打印页是三类信息的组合交付视图,不再作为“页面内所有字段都是正文”的判据;字段归属只由来源决定。
|
||||
- 布局可以响应设备变化:PC 合计使用语义表格 `tfoot`,移动端在明细卡片组末尾使用等价的“明细合计”汇总行;字段含义、金额来源和可访问名称必须一致。
|
||||
- 模式只增加能力和上下文:edit 增加维护/提交能力,approval 增加快照、决策和 workflow 历史,readonly 不增加业务动作。不得为不同角色复制或重新计算正文。
|
||||
|
||||
### R2 明细合计只展示服务端权威总额
|
||||
|
||||
- 明细区合计显示统一工作台模型中的服务端 `totalAmount`,不得在 PC 表格、移动卡片或各页面用 `items.reduce()` 生成可展示的第二总额。
|
||||
- 若实现完整性核对,明细派生和权威总额只能在共享模型构建边界比较一次;派生值仅用于判断完整性,不得作为报销金额展示或提交。
|
||||
- 不一致不是正常业务“差额”,必须显示为数据完整性故障,文案明确为“报销总额与明细汇总不一致,请联系管理员”,并阻止审批动作;不得暗示用户可手工修正总额。
|
||||
- 明细金额无法安全解析时不得伪造差额;应明确提示无法完整核对并按既有关键内容未就绪合同阻止审批动作。
|
||||
- 金额比较必须使用十进制定点/分值口径,不能使用二进制浮点直接累加。
|
||||
|
||||
### R3 审批历史在 PC 右栏只保留一份
|
||||
|
||||
- PC approval 将完整 workflow 处理历史移到右侧审批上下文,位于审批动作之后;主列不再重复 timeline。
|
||||
- 历史条目保留动作、处理人、时间和完整意见;正常短流程不新增“历史摘要 + 完整历史”双层表达。
|
||||
- 历史增长不得把主要审批动作挤出可达区域;右栏的 sticky、滚动和长意见折行策略在设计阶段明确,并覆盖 1280px 与长退回意见。
|
||||
- 移动端不复制 PC 右栏,完整历史继续位于正文之后、底部动作之前或安全区动作之前的既有阅读顺序。
|
||||
|
||||
### R4 处理进度与处理历史明确分工
|
||||
|
||||
- `ProcessingProgress` 保留在普通报销详情,回答员工“当前走到哪一步、付款到哪一步”,并继续提供唯一付款批次入口。
|
||||
- workflow timeline 保留在审批任务,回答审批人“谁处理过、执行了什么动作、留下什么意见”。
|
||||
- 两者可以使用不同视觉结构,但动作名称、人员、时间和状态含义不得互相矛盾;本任务不强行合并为一个组件。
|
||||
|
||||
### R5 先改进,再由 AC11 完成业务验收
|
||||
|
||||
- 用户决定不再以 AC11 真人反馈作为本子任务的实施前置;本规划经最新评审明确批准后即可 `task.py start`。
|
||||
- 本子任务只改变已冻结的合计、历史位置和上下文表达,不借“先改进”继续扩充范围;commit、push、部署仍分别等待明确批准。
|
||||
- 父任务 AC11 仍是最终业务验收:部署后至少记录一次员工真实重提与一次财务真实复审,核对员工提示、正文一致性、明细合计、PC 历史位置和进度/历史理解。
|
||||
- 真人反馈若推翻已上线行为,必须作为新事实回到规划,不以现场猜测直接改代码;在 AC11 完成前不得归档父任务。
|
||||
|
||||
### R6 不扩展业务与数据范围
|
||||
|
||||
- 不修改 schema、migration、B1/B2、费用类型、workflow 状态机、材料权限或生产数据。
|
||||
- 不操作 `0016/0017/0018`;它们仍用于父任务 AC11 的真人反馈。
|
||||
- 不借本任务处理部署脚本、`.gitignore`、错误色 token、AGENTS skill 名称或其他治理债。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] AC1:同一合成报销数据在 PC/移动 edit、readonly、approval 中的报销头、事由、明细、合计、票据/附件和申报关联信息逐字段一致;差异仅来自已批准的能力与处理上下文。
|
||||
- [x] AC2:PC 费用明细表使用 `tfoot` 显示“明细合计 + 服务端权威总额”;移动卡片组末尾显示语义等价汇总。三模式、不同用户均从同一工作台模型取值。
|
||||
- [x] AC3:代码与静态检查证明 PC/移动明细组件不存在用于展示总额的 `items.reduce()` 或页面级金额汇总;展示金额与页头、打印页使用同一 `totalAmount`。
|
||||
- [x] AC4:mismatch 与金额不可解析 fixture 分别触发准确故障提示并使报销审批 fail-closed;正常 fixture 不显示差额或“0 条”说明。
|
||||
- [x] AC5:PC approval 的完整历史只在右栏出现一次且位于动作之后;主列 timeline 为 0。1280px、长意见和 6 条历史下动作可达、页面无横向溢出。
|
||||
- [x] AC6:移动 approval 的历史仍在正文末尾,底部动作不遮挡;375px 与横屏无横向溢出,触控目标不小于 44×44。
|
||||
- [x] AC7:普通详情继续使用 `ProcessingProgress`,审批继续使用 workflow timeline;付款批次入口仍只有一处,二者没有状态/动作语义冲突。测试明确证明票据本体属于正文,而票据金额证据状态属于可按模式展示的上下文。
|
||||
- [x] AC8:edit 维护能力、readonly 零维护能力、approval 零报销维护能力和 B2 未归属恢复均无回归。
|
||||
- [x] AC9:打印视图继续显示服务端权威合计且不获得工作台 mutation;二维码、打印人和打印时间不进入交互工作台,审批记录不被误纳入三模式正文一致性断言。
|
||||
- [x] AC10:无 schema、migration、flag、seed 或生产数据写入;通过 typecheck/build、相关 Playwright、UI strict/self-test、Trellis governance 和 `git diff --check`。
|
||||
- [ ] AC11:本子任务部署后,父任务记录员工真实重提和财务真实复审反馈;反馈未完成不阻止本子任务实施,但继续阻止父任务归档。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 共享模型:严格十进制金额解析、权威总额透传、正常一致、mismatch、金额不可解析和空明细边界。
|
||||
- PC/移动组件:`tfoot`/移动汇总行、三模式逐字段一致、编辑操作列 colspan、正常无解释、完整性故障可访问文案。
|
||||
- 审批行为:完整性故障四动作 fail-closed;PC 历史单一右栏位置、长意见与 6 条记录;移动历史和安全区动作不遮挡。
|
||||
- 上下文一致性:同一 fixture 对照 `ProcessingProgress` 与 workflow timeline 的已发生事件;未来付款阶段只出现在进度模型,付款批次入口唯一。
|
||||
- 回归:edit/readonly/approval capability、B2 未归属恢复、打印视图、移动离线缓存、非报销 workflow。
|
||||
- 视觉与可访问性:1280px、375px、移动横屏,无页面级横向溢出;语义表格、键盘顺序、非颜色单一表达和 44×44 触控目标。
|
||||
- 工程门禁:typecheck/build、相关 Playwright、UI strict/self-test、Trellis task validate/governance、process contract、docs registry、敏感内容检查和 `git diff --check`。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 合并 `ProcessingProgress` 与 workflow timeline,或重做付款流程。
|
||||
- 重新设计整张打印页、改变打印字段或归档政策;仅把它作为内容口径证据并保持回归。
|
||||
- 在取得 AC11 反馈前据此宣称真人体验问题已经解决或归档父任务。
|
||||
- 其他非报销 workflow 页面和历史债务。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "reimbursement-readonly-content-consistency",
|
||||
"name": "reimbursement-readonly-content-consistency",
|
||||
"title": "报销只读内容一致性与审批上下文收敛",
|
||||
"description": "统一报销工作台三模式的报销正文口径,补齐权威明细合计,并收敛 PC 审批历史与右侧决策上下文。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "trellis,client,docs",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "shunli",
|
||||
"assignee": "shunli",
|
||||
"createdAt": "2026-09-02",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": "e48514d",
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "09-01-reimbursement-one-page-approval-workbench",
|
||||
"relatedFiles": [],
|
||||
"notes": "本地实施与专项验证完成,等待 diff 复核;权威合计、两类完整性 fail-closed、PC 历史右置和进度/历史职责合同已落地。未 commit、push、部署;AC11 仍待部署后真人单据验证。",
|
||||
"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,92 @@
|
||||
# 技术设计:审批退回驳回语义与动作布局
|
||||
|
||||
## 1. 决策
|
||||
|
||||
采用按实体区分的动作模型,不全局合并状态机:
|
||||
|
||||
- 报销:新待办对客户端宣告 `approve / return / transfer`,不宣告 `reject`;界面文案为“通过 / 退回修改 / 转审”。
|
||||
- 请假、采购、差旅:保留 `approve / reject / return`;补卡保留 `approve / reject`。
|
||||
- 本任务只调整报销动作投影与报销 PC/移动布局;通用审批页仍消费其他四类实体的既有动作。
|
||||
|
||||
这不是把 `reject` 改名为 `return`,也不改写任何已存在的 outcome。新报销审批的修改诉求统一走原有 return 事务。
|
||||
|
||||
## 2. 服务端边界
|
||||
|
||||
在 `listAllowedActionsForTask` 中按实体产生对外 capability:
|
||||
|
||||
```text
|
||||
reimbursement -> approve, return, [transfer]
|
||||
attendance_correction -> approve, reject, [transfer]
|
||||
leave/purchase/travel -> approve, reject, return, [transfer]
|
||||
```
|
||||
|
||||
`transfer` 仍由既有权限决定。本任务不增加或删除审批权限。
|
||||
|
||||
兼容策略:
|
||||
|
||||
- `/workflow/tasks/:taskId/reject` 与 `/workflow/batch-reject` 暂不删除,避免旧页面、离线队列或内部调用在同一版本中发生破坏性变化。
|
||||
- 新页面以 `allowedActions` 为可见能力来源,不为报销渲染 reject。
|
||||
- 既有 `rejected` 业务单、task、instance、history 和 notification 原样保留;报销 `draft/rejected` 的编辑与重提能力不变。
|
||||
- 不迁移数据库、不改 Prisma schema、不重写历史状态。
|
||||
|
||||
## 3. PC 报销动作区
|
||||
|
||||
默认态只保留一个紧凑动作组:
|
||||
|
||||
```text
|
||||
审批决定
|
||||
────────────────────────
|
||||
[ 通过 ] [ 退回修改 ]
|
||||
────────────────────────
|
||||
转审
|
||||
```
|
||||
|
||||
- “通过”和“退回修改”同一行、同高;主按钮使用实心主色,退回使用次级描边。
|
||||
- 两列在右栏宽度内稳定铺满,不使用一个通栏按钮加若干内在宽度小按钮。
|
||||
- “转审”保持现有 ghost/低频区域,不恢复更多菜单,也不改变其能力条件。
|
||||
- 点击“退回修改”后才展开意见和引用清单;确认仍二次确认。
|
||||
- fail-closed 原因继续紧贴动作组上方;被阻断时通过、退回修改、转审全部禁用。
|
||||
|
||||
为避免影响共享组件的其他消费者,优先由 `ReimbursementApprovalAdapter` 注入一个报销专用 action-group 节点;不改变 `BusinessDetailActionPanel` 其他消费者的 DOM 顺序。
|
||||
|
||||
## 4. 移动报销动作区
|
||||
|
||||
默认 sticky action bar 改为两个直接动作:
|
||||
|
||||
```text
|
||||
[ 退回修改 ] [ 通过 ]
|
||||
```
|
||||
|
||||
- 两个按钮均不小于 44px 高;通过保持主视觉,退回为次级样式。
|
||||
- 点击退回后,在正文动作卡中渐进展开意见、异常优先引用清单和确认动作。
|
||||
- 转审继续留在正文的低频处理区,不塞入 sticky 主动作组。
|
||||
- 离线报销继续全动作 fail-closed,不因本次减少按钮而开放离线退回或驳回。
|
||||
|
||||
## 5. 数据与历史
|
||||
|
||||
- 新退回历史仍写 `workflow_history.action=return`、task outcome=`returned`、instance status=`terminated`、报销 status=`draft`。
|
||||
- 历史 rejected 报销仍显示“已驳回”,并可按既有规则修改重提。
|
||||
- 打印页、处理进度和审批历史继续同时认识 return/reject;不得因为新 UI 不再产生报销 reject 而删除历史映射。
|
||||
- 通知继续使用 `reimbursement.returned`;历史 `reimbursement.rejected` 类型保留。
|
||||
|
||||
## 6. 测试边界
|
||||
|
||||
- 服务端:五类实体的 allowedActions 矩阵;报销新任务不含 reject,其他实体保持原样。
|
||||
- 报销 PC:默认动作区只有通过、退回修改、转审;无驳回按钮;两项核心动作同组;退回展开/引用/确认保持。
|
||||
- 报销移动:sticky 包含退回修改与通过;无驳回入口;离线全部禁用。
|
||||
- 兼容:历史 rejected 报销仍可编辑重提,history/print 显示不变;旧 reject endpoint 的既有测试继续通过。
|
||||
- 回归:请假、补卡、采购、差旅动作集合、确认文案和页面布局不变。
|
||||
|
||||
## 7. 风险与回滚
|
||||
|
||||
- 风险:`allowedActions` fixture 广泛硬编码,需只修改报销 fixture,不能全局删除 reject。
|
||||
- 风险:移动通用审批页与报销专用工作台并存,必须验证报销路由最终只呈现一套动作。
|
||||
- 风险:旧 bundle 在切换窗口仍可能调用兼容 reject endpoint;因此首轮上线后仍可能出现少量新 rejected 历史,这是兼容期预期,不做数据修正。
|
||||
- 回滚:纯代码回滚即可恢复原动作投影和三按钮布局;无数据库回滚步骤。
|
||||
|
||||
## 8. 延后事项
|
||||
|
||||
- 请假/采购/差旅 return 缺少结果通知类型与发送,是独立通知治理问题。
|
||||
- 是否最终退役报销 reject 与 batch-reject API,需在兼容期和调用审计后另立任务。
|
||||
- 转审是否隐藏或进入“更多”仍等待真实使用证据。
|
||||
|
||||
@@ -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,64 @@
|
||||
# 实施计划:审批退回驳回语义与动作布局
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 为 `listAllowedActionsForTask` 增加实体动作矩阵,报销不再宣告 reject,其他四类保持现状。
|
||||
2. 更新服务端聚焦测试,覆盖五类实体的正向与负向 allowedActions,并保留 reject/return endpoint 的兼容测试。
|
||||
3. 收敛 `ReimbursementApprovalAdapter` 的 PC 动作组:通过与退回修改同组,删除报销可见驳回入口,转审不动。
|
||||
4. 收敛移动报销 sticky action bar:退回修改与通过并列;退回意见、引用清单和确认仍渐进展开;删除离线时强制渲染驳回的旧分支。
|
||||
5. 更新报销 PC/移动 E2E fixtures 和断言:无驳回、动作同组、退回展开、fail-closed、离线禁用、历史 rejected 兼容。
|
||||
6. 回归通用审批页的请假、补卡、采购、差旅动作集合与确认流程,确认共享组件 DOM 未漂移。
|
||||
7. 仅在稳定合同确有变化时更新 `docs/需求-统一审批中心规约.md` 与 `.trellis/spec/hyb-oa/approval-workflow.md`,明确报销用“退回修改”、其他实体按后果保留驳回。
|
||||
|
||||
## 风险文件
|
||||
|
||||
- `server/src/routes/workflow.js`:共享工作流动作投影,必须覆盖五类实体。
|
||||
- `client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx`:PC/移动和离线分支集中,不能误删 return 引用能力或 fail-closed。
|
||||
- `client/src/pages/MobileApprovalsPage.tsx`:通用移动审批回归面,原则上不改。
|
||||
- `client/src/components/patterns/BusinessDetailTemplate.tsx`:共享动作面板,原则上不改。
|
||||
- `client/src/lib/workflowActionConfirm.ts`:通用后果文案,原则上不改;报销仅不再进入 reject 分支。
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
cd server && npm run prisma:validate
|
||||
cd server && node --check src/routes/workflow.js
|
||||
cd server && npm test -- tests/unit/routes/workflow.permissions.test.js tests/unit/routes/workflow.batch.test.js tests/unit/routes/workflow.travel.test.js
|
||||
cd server && npm test -- tests/unit/routes/reimbursements.detail.test.js tests/unit/routes/reimbursements.invoice-item-link.test.js
|
||||
cd client && npm run build
|
||||
cd client && npx playwright test tests/e2e/reimbursement-content-consistency.spec.ts --workers=1 --reporter=list
|
||||
cd client && npx playwright test tests/e2e/workflow-action-confirm-ui.spec.ts --workers=1 --reporter=list
|
||||
cd client && npx playwright test tests/e2e/mobile-approvals-offline.spec.ts --grep '报销证据不进入 IndexedDB' --workers=1 --reporter=list
|
||||
node scripts/qa/check-ui-design-rules.mjs --strict
|
||||
node scripts/qa/check-ui-design-rules.mjs --self-test
|
||||
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 09-04-approval-return-reject-actions
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 验证结果
|
||||
|
||||
- Prisma schema 与 `workflow.js` 语法检查通过。
|
||||
- 服务端动作矩阵、旧 reject 兼容及四类非报销回归共 24/24 通过;历史报销详情与 rejected 可维护证据回归 48/48 通过。
|
||||
- 前端 typecheck、生产构建通过;报销静态契约 9/9、审批动作浏览器回归 11/11 通过。
|
||||
- 移动报销离线专项未进入断言:本机缺 Playwright WebKit executable;保留为环境边界,不记为产品失败,也未安装新浏览器或更改测试配置。
|
||||
- UI strict 扫描 155 文件、0 findings;self-test PASS。
|
||||
- process contract、Trellis governance、task validate 与 `git diff --check` 通过。
|
||||
- docs registry 审计为 0 error / 1 warning;warning 为仓库既有 22 个未注册 docs 文件,本任务未新增该类文件。
|
||||
|
||||
## 人工验收
|
||||
|
||||
1. PC 报销审批右栏:通过与退回修改同组、无驳回、转审位置不变;1280px 无溢出。
|
||||
2. 移动报销审批:底部直接显示退回修改与通过;点击退回后才展开意见与引用清单。
|
||||
3. 任一报销 fail-closed fixture:原因紧邻动作,所有可见动作均禁用。
|
||||
4. 历史 rejected 报销仍显示已驳回、仍可修改重提;历史 timeline/打印无丢失。
|
||||
5. 请假、补卡、采购、差旅各打开一个:动作数量、文案和确认流程无变化。
|
||||
|
||||
## 提交与部署边界
|
||||
|
||||
- commit 使用中文并引用 `09-04-approval-return-reject-actions`。
|
||||
- 实施后先做只读 review;用户批准后才能提交。
|
||||
- push、生产部署和生产数据检查均需分别获得明确授权。
|
||||
- 无 migration/schema/seed/flag/生产数据写入;回滚到上一构建即可。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 审批退回驳回语义与动作布局
|
||||
|
||||
## Goal
|
||||
|
||||
基于系统真实后果而不是行业常识,厘清“退回”与“驳回”的实体差异;优先收敛唯一真实使用的报销审批链路,使审批人只面对必要动作,并修复当前“通栏主按钮 + 零散小按钮”的割裂布局。
|
||||
|
||||
## Background
|
||||
|
||||
当前报销审批右栏把“通过”作为通栏主按钮,“退回”“驳回”作为下一行两个内在宽度按钮,“转审”再单独分组。该布局既失衡,也把两个对报销申请人后果近似的动作同时交给审批人判断。
|
||||
|
||||
报销现有“驳回”并未兑现通常意义上的终止:`rejected` 报销仍可在原单编辑并重提。因此移除报销审批端的“驳回”不损失已存在的终止能力,而是撤掉一个与真实后果不符的语义承诺。审批人若认为该笔费用根本不应申报,现阶段在退回意见中说明,申请人收到后可删除草稿;真正的审批人终止能力如有现实需求,必须另立状态机任务,不能继续借用名不副实的 reject。
|
||||
|
||||
只读核查确认五类实体不能全局合并:
|
||||
|
||||
| 实体 | 退回后 | 驳回后 | 后续能力 | 结论 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 报销 | 原单回 `draft` | 原单为 `rejected` | 两者均可在原单修改、重提,并创建新 workflow instance | 新审批只保留“退回修改” |
|
||||
| 请假 | 原单回 `draft` | 原单为 `rejected` | 两者均可修改、重提并重新预留假期;但只有驳回发送结果通知 | 本次不改 |
|
||||
| 补卡 | 不提供退回 | 原单为 `rejected` | 无原单编辑/重提 | 本次不改 |
|
||||
| 采购 | 原单回 `draft` | 原单为 `rejected` | 只有退回后能修改、重提 | 两动作有实质差异,本次不改 |
|
||||
| 差旅 | 原单回 `draft` | 原单为 `rejected` | 只有退回后能修改、重提 | 两动作有实质差异,本次不改 |
|
||||
|
||||
服务端证据与文件锚点见 `research/return-reject-semantics.md`。国内 OA 的通行叫法仅作为参考,目标口径由本系统状态、编辑/重提、通知和释放副作用共同决定。
|
||||
|
||||
## Scope
|
||||
|
||||
- 按五类实体建立服务端动作矩阵,修改新报销待办的 `allowedActions` 投影。
|
||||
- 收敛报销 PC 与移动审批动作文案、分组和渐进展开方式。
|
||||
- 增加服务端矩阵测试、报销 PC/移动交互测试和四类非报销回归。
|
||||
- 保留历史状态与旧接口兼容,必要时同步审批规约和稳定工程 spec。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1:按实体投影动作
|
||||
|
||||
- 服务端 `allowedActions` 必须按实体后果产生,不再默认给所有非补卡实体同一组动作。
|
||||
- 新报销待办只宣告 `approve / return`,有既有转审权限时再宣告 `transfer`;不再向 PC 或移动客户端宣告 `reject`。
|
||||
- 请假、采购、差旅继续宣告 `approve / reject / return`;补卡继续宣告 `approve / reject`;各实体的转审仍沿用现有权限。
|
||||
|
||||
### R2:报销只保留“退回修改”
|
||||
|
||||
- 报销审批界面将 return 展示为“退回修改”,继续调用既有 return 事务。
|
||||
- 退回后必须保持:报销单回 `draft`、原单号保留、历史追加 returned、申请人可修改并重提、重提新建 workflow instance。
|
||||
- 不得把 reject 改名后继续调用 reject,也不得把历史 rejected 重写为 returned。
|
||||
|
||||
### R3:PC 动作布局收敛
|
||||
|
||||
- 报销 PC 右栏的“通过”和“退回修改”必须在同一个两列动作组中,同高、铺满可用宽度,以颜色和样式表达主次,不再使用一枚通栏按钮加零散小按钮。
|
||||
- “转审”保持现有低频区域、权限和展开行为;不恢复“更多”菜单。
|
||||
- 点击“退回修改”后才展开意见、异常优先引用清单与确认入口。
|
||||
|
||||
### R4:移动动作布局收敛
|
||||
|
||||
- 报销移动端 sticky action bar 必须直接显示“退回修改”和“通过”,触控高度不小于 44px。
|
||||
- 退回编辑内容在点击后渐进展开;转审继续留在低频处理区。
|
||||
- 报销离线仍 fail-closed,不因减少一个按钮而开放任何远程审批动作。
|
||||
|
||||
### R5:安全与解释不退化
|
||||
|
||||
- 不增加或删除现有 `decisionReady`、B1/B2、权限、自审批或内容完整性条件。
|
||||
- fail-closed 原因继续紧邻动作组上方;被阻断时所有可见动作全部禁用。
|
||||
- 通过、退回修改和转审继续使用现有二次确认、必填意见与审计规则。
|
||||
|
||||
### R6:历史和接口兼容
|
||||
|
||||
- 既有 rejected 报销继续显示“已驳回”,仍按现有规则允许修改重提;打印、处理进度、审批历史和通知映射不得删除 reject。
|
||||
- 本任务不迁移数据库、不修改 schema、不改写 task/instance/history/outcome。
|
||||
- 旧单项 reject endpoint 和隐藏的 batch reject endpoint 作为兼容入口暂时保留,但不得新增或恢复任何报销可见入口;其最终退役另行立项。
|
||||
|
||||
### R7:其他实体零行为变化
|
||||
|
||||
- 请假、补卡、采购、差旅的动作集合、状态写入、通知、释放逻辑、确认文案和页面布局本次不变。
|
||||
- 请假/采购/差旅 return 缺少结果通知属于已识别既有缺口,另记待办,不在本任务顺手修复。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1:服务端测试逐项证明 reimbursement=`approve,return[,transfer]`、leave/purchase/travel=`approve,reject,return[,transfer]`、attendance_correction=`approve,reject[,transfer]`;无权限或已处理任务仍返回空动作。
|
||||
- [ ] AC2:报销 PC 和移动 fixture 的 `allowedActions` 及可见 UI 均不含 reject;页面上“驳回”“确认驳回”按钮数为 0。
|
||||
- [ ] AC3:PC 报销右栏“通过 / 退回修改”同处一个两列动作组、等高且无横向溢出;不存在旧的一通栏加两个小按钮布局。
|
||||
- [ ] AC4:移动报销 sticky action bar 默认直接包含“退回修改 / 通过”,两者触控高度至少 44px;转审不进入该主动作组。
|
||||
- [ ] AC5:展开退回修改前,意见输入和引用清单不存在;展开后意见必填、异常引用项优先、确认弹窗准确说明回到申请人修改态。
|
||||
- [ ] AC6:任一现有 fail-closed 条件成立时,原因紧贴动作组上方,通过、退回修改、转审全部禁用;正常报销的可见动作不被误伤。
|
||||
- [ ] AC7:执行退回修改后,服务端响应和持久化仍为 returned/draft;修改后重提创建新 workflow instance 并从重新匹配的首个可执行节点开始。
|
||||
- [ ] AC8:历史 rejected 报销仍可读取、显示、编辑和重提;打印/时间线仍正确显示“驳回”,不发生数据迁移或历史改写。
|
||||
- [ ] AC9:旧 reject endpoint 的既有兼容测试继续通过;产品 UI 和新 `allowedActions` 不再为报销提供调用路径。
|
||||
- [ ] AC10:请假、补卡、采购、差旅分别通过动作集合与确认流程回归;共享 `BusinessDetailActionPanel` 的其他消费者 DOM 顺序不变。
|
||||
- [ ] AC11:typecheck、build、相关后端测试、PC/移动 E2E、UI strict/self-test、process contract、Trellis governance 与 `git diff --check` 全部通过。
|
||||
- [ ] AC12:面向用户可测试前,需另行批准 push/部署,并通过公网 bundle、PC 1280px、移动端和五类审批回归。
|
||||
|
||||
## Test Plan
|
||||
|
||||
- 服务端:五类实体 `allowedActions` 矩阵、无权限/已处理负向分支、报销 return 状态与重提新实例、旧 reject endpoint 兼容。
|
||||
- PC:报销两列动作组、无驳回入口、退回渐进展开、引用与确认、正常态及 fail-closed;四类通用审批动作回归。
|
||||
- 移动:报销 sticky 双动作、44px 触控、离线 fail-closed、退回编辑;通用移动审批不受影响。
|
||||
- 历史:rejected 报销读取、编辑、重提、打印和 timeline 映射不变。
|
||||
- 门禁:前后端聚焦测试、typecheck/build、UI strict/self-test、process contract、Trellis governance、`git diff --check`。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 删除 reject/batch-reject API、迁移或清理历史 rejected 数据。
|
||||
- 调整请假、补卡、采购、差旅的动作语义或页面布局。
|
||||
- 修复请假/采购/差旅 return 通知缺口。
|
||||
- 新增退回上一节点、退回指定人或续接旧 workflow instance 的能力。
|
||||
- B1/B2、审批链配置、权限模型、自审批规则和状态机的其他改造。
|
||||
- 转审入口归属、全局按钮体系或其他页面视觉重构。
|
||||
- 生产部署和真实数据写入。
|
||||
|
||||
## Risks And Deferred Items
|
||||
|
||||
- 旧 bundle 在发布切换窗口仍可能调用兼容 reject endpoint,因此短暂兼容期内仍可能产生 rejected 报销;不自动修正此类历史。
|
||||
- 采购和差旅页面存在“驳回后补材料”类文案与服务端只允许 draft 编辑之间的既有漂移,需独立任务处理,不混入本次报销收敛。
|
||||
- 请假/采购/差旅 return 不发送结果通知;需按各模块实际使用优先级另行治理。
|
||||
- 请假/采购/差旅 return 结果通知缺口应作为本任务之后的独立高优先级任务,优先于一般视觉和代码清理债;不得在本任务中夹带实现。
|
||||
- 转审是否隐藏、保留或进入更多菜单,继续等待真实使用证据。
|
||||
|
||||
## Planning Gate
|
||||
|
||||
本任务为复杂 workflow + UI 改动,`design.md`、`implement.md` 和研究记录已齐备。只有用户审批本版最终规划后,才可执行 `task.py start` 并修改产品代码。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 退回 / 驳回语义核查
|
||||
|
||||
## 核查边界
|
||||
|
||||
只读检查服务端状态写入、业务对象编辑/重提条件、工作流实例创建、通知类型、额度/预算释放、前端动作投影和历史展示。未查询或修改生产数据。
|
||||
|
||||
## 总结
|
||||
|
||||
五类实体不能用一套“退回 / 驳回等价”结论:
|
||||
|
||||
| 实体 | 退回 | 驳回 | 申请人后续能力 | 通知 | 结论 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 报销 | 业务单回 `draft`;实例 `terminated`;task outcome=`returned` | 业务单回 `rejected`;实例/task=`rejected` | 两者均可编辑并在原单重提;重提创建新实例、重新从首个可执行节点开始 | 两者各有通知类型和不同标题 | 当前用户后果近似,审批端同时暴露两个动作没有足够价值 |
|
||||
| 请假 | 业务单回 `draft`;释放已预留假期 | 业务单回 `rejected`;同样释放已预留假期 | 两者均可编辑并在原单重提;重提创建新实例并重新预留 | 驳回有通知;退回当前没有对应通知类型 | 业务能力近似但副作用不等价,不能顺手合并 |
|
||||
| 补卡 | 不在 `allowedActions` 中,return handler 也无业务对象分支 | 业务单变 `rejected` | 没有原单编辑/重提入口 | 驳回有通知 | 只有驳回,语义明确 |
|
||||
| 采购 | 业务单回 `draft`;释放预算 | 业务单变 `rejected`;同样释放预算 | 只有 `draft` 能编辑/提交;驳回后原单不能继续修改 | 驳回有通知;退回当前没有对应通知类型 | 两者有实质差异,必须保留 |
|
||||
| 差旅 | 业务单回 `draft`;释放预算 | 业务单变 `rejected`;同样释放预算 | 只有 `draft` 能编辑/提交;驳回后原单不能继续修改 | 驳回有通知;退回当前没有对应通知类型 | 两者有实质差异,必须保留 |
|
||||
|
||||
## 关键证据
|
||||
|
||||
### 服务端动作投影
|
||||
|
||||
- `server/src/routes/workflow.js:684-703`:当前所有可审批实体默认获得 `approve + reject`,补卡以外再获得 `return`;差异仅按补卡特殊处理,没有按真实后果建模。
|
||||
- `server/src/routes/workflow.js:1858-2013`:驳回把 task/outcome、workflow instance 和各业务对象写成 `rejected`;请假释放假期,采购/差旅释放预算。
|
||||
- `server/src/routes/workflow.js:2990-3136`:退回把 task outcome 写成 `returned`、instance 写成 `terminated`,报销/请假/采购/差旅业务对象回到 `draft`;请假释放假期,采购/差旅释放预算;补卡没有业务对象更新分支。
|
||||
|
||||
### 编辑与重提
|
||||
|
||||
- 报销:提交及维护接受 `draft/rejected`;重提在 `server/src/routes/reimbursements.js:4556` 创建新 workflow instance。
|
||||
- 请假:编辑、提交接受 `draft/rejected`(`server/src/routes/leave.js:805-850`、`:1009-1010`);`:1113-1130` 创建新 workflow instance;`:1169-1184` 重新预留假期。
|
||||
- 采购:权限投影、编辑、提交均只接受 `draft`(`server/src/routes/purchaseRequests.js:675-678`、`:1652-1653`、`:1828-1829`);提交在 `:1959-1975` 创建新实例。
|
||||
- 差旅:权限投影、编辑、提交均只接受 `draft`(`server/src/routes/travelRequests.js:493-496`、`:808-809`、`:945-946`);提交在 `:1032-1050` 创建新实例。
|
||||
- 补卡:创建时直接创建 workflow instance(`server/src/routes/attendanceCorrections.js:307-322`),后续只有取消接口,没有原单编辑/重提接口。
|
||||
|
||||
### 通知
|
||||
|
||||
- `server/src/routes/workflow.js:1073-1096`:五类实体都有 rejected 通知;returned 仅映射报销。
|
||||
- `server/src/services/notifications/workflowCopy.js:238-253`:退回和驳回通知文案分别使用“已退回”“已驳回”。
|
||||
- 因此请假/采购/差旅执行退回时,`queueWorkflowOutcomeNotifications` 会因 notification type 为 `null` 直接返回。该缺口是既有行为,本任务不顺手扩张为通知治理。
|
||||
|
||||
### 现有产品合同与实现漂移
|
||||
|
||||
- `docs/需求-统一审批中心规约.md:205-242` 将驳回定义为终止或按模块重新发起,将退回定义为回到修改态并保留原单。
|
||||
- 采购/差旅服务端与该合同一致;报销/请假允许 `rejected` 原单直接编辑重提,比文档中的默认驳回语义更宽松。
|
||||
- `client/src/lib/workflowActionConfirm.ts` 仍按通用合同解释:退回原单修改,驳回通常重新发起。该文案对采购/差旅成立,对报销/请假不完全成立。
|
||||
|
||||
## 布局证据
|
||||
|
||||
- `client/src/components/patterns/BusinessDetailTemplate.tsx:83-138` 将 primary、secondary、more 拆成三个视觉分组。
|
||||
- 报销在 `client/src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx:398-414` 将“通过”放 primary,将“退回/驳回”放 secondary,将“转审”放 more,形成当前通栏主按钮、两个内在宽度按钮和分隔后的转审。
|
||||
- 移动报销在同文件 `:361-376` 把三个次动作放正文卡片,只把“通过”放 sticky action bar;信息层级与 PC 不一致。
|
||||
- `ui-ux-pro-max` 的相关规则要求危险动作二次确认、错误靠近字段/动作、触控目标至少 44px;现有确认和 fail-closed 规则应保留。
|
||||
|
||||
## 规划结论
|
||||
|
||||
采用“按实体区分”,但本次产品改动只收敛 OA 主链路报销:
|
||||
|
||||
1. 新报销审批不再向 UI 宣告 `reject`,仅提供“通过 / 退回修改 / 转审”。
|
||||
2. 报销“退回修改”继续调用既有 return 路径,保持 `draft + returned history + returned notification`。
|
||||
3. 历史 `rejected` 报销继续按原状态展示和允许修改重提;不改写历史记录、不迁移数据。
|
||||
4. 旧 reject endpoint 和隐藏的 batch reject endpoint暂作兼容保留,不新增入口;另行治理 API 退役,避免本次视觉改造扩大为破坏性接口变更。
|
||||
5. 请假、补卡、采购、差旅的动作与布局本次不改;它们作为回归面。请假/采购/差旅 return 通知缺口另记待办。
|
||||
|
||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
在新工单中引用
屏蔽一个用户