OANEXT2 增加OA权限矩阵自动验证

这个提交包含在:
2026-05-23 22:08:19 +08:00
父节点 724fce0344
当前提交 de5f3253d1
共修改 3 个文件,包含 628 行新增和 2 行删除
@@ -4,9 +4,10 @@ OALAUNCH1-1,新增最终启用证据汇总脚本,"新增 QA-only 汇总脚本,
OALAUNCH1-2,回写最终启用事实源和操作清单,"更新管理员手册、员工快速上手、人工试运行报告、文档索引、事实源矩阵和项目 skill,固化最终启用前仍需补齐的门禁。","文档能检索到 OALAUNCH1、PASS_OA_LAUNCH_READY、7 天稳定化、备份恢复演练、权限矩阵、Go/No-Go 和 P2 冻结口径。","受影响流程:管理员/员工按手册执行上线前收口;验证步骤:rg -n ""OALAUNCH1|PASS_OA_LAUNCH_READY|备份恢复|权限矩阵|Go/No-Go|7 天稳定化"" docs .agents/skills/hyb-oa-delivery/SKILL.md。","rg|manual",DONE,DONE,DONE,"docs/管理员手册.md;docs/员工快速上手.md;docs/trialrun/人工试运行报告.md;docs/文档索引.md;docs/文档事实源状态矩阵.md;.agents/skills/hyb-oa-delivery/SKILL.md","[""OALAUNCH1-0""]","优先更新现有事实源,不新建孤立文档;rg、audit-docs 和 quick_validate 已通过,audit-docs 保留既有 unregistered docs files 24 warning。"
OALAUNCH1-3,OAOPS1 真实业务闭环收口门禁,"正式启用前必须补齐 OAOPS1-3、OAOPS1-4、OAOPS1-5、OAOPS1-6,不得只用自动巡检替代真实业务闭环。","至少 1 条真实报销 PDF OCR -> 审批 -> 付款批次 -> 银行回单 -> 已付款 -> AP 导出闭环;至少 1 条采购/费用审批并关联项目或合同;至少 1 条合同台账含归档位置和权限检查;每日问题登记机制可执行。","受影响流程:报销 -> 审批 -> 付款 -> AP;采购/费用 -> 审批 -> 项目/合同;合同台账 -> 归档 -> 权限;验证步骤:人工在公网 OA 完成真实业务样本,运行 run-oa-final-launch-readiness.sh 汇总 OAOPS1 状态。","manual|bash",TODO,TODO,TODO,"external|issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv","[""OALAUNCH1-1""]","真实票据、银行回单、合同正文和敏感报价不得进入仓库、聊天或自动化报告;阻断即建修复合同。"
OALAUNCH1-4,7 天稳定化与每日巡检门禁,"连续 7 天运行 OAOPS1 每日只读巡检并做人工日收口,最终汇总无 blocker。","每日 summary.json 可读;最终 7 天无不可解释 blocker;非阻断 finding 均有 owner、处理日期或接受理由。","受影响流程:每日巡检 -> 人工问题登记 -> Day 7 汇总;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh;Day 7 执行 OALAUNCH_STABILIZATION_START_DATE=YYYY-MM-DD run-oa-final-launch-readiness.sh。","bash|manual",TODO,TODO,TODO,"artifacts/qa-flow/oa-real-use-daily-*|scripts/qa/run-oa-real-use-daily.sh","[""OALAUNCH1-3""]","自动报告只说明运行状态;Day 7 Go/No-Go 仍需人工拍板;脚本必须按指定起始日期检查连续 7 天,不得用历史报告凑数。"
OALAUNCH1-5,权限矩阵最终验收,"正式启用前复核员工、主管、财务、HR、CEO、管理员关键入口和 API 权限,重点验证普通员工不能越权。","普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口;各业务角色能访问必要入口;权限负例报告脱敏留存。","受影响流程:登录 -> 入口可见性 -> API 直连负例 -> 审计;验证步骤:复用 OA-only smoke readiness、权限直连审计或人工浏览器验证,并将证据路径提供给 run-oa-final-launch-readiness.sh。","playwright|manual|node",TODO,TODO,TODO,"external|scripts/qa/check-oa-only-smoke-readiness.mjs","[""OALAUNCH1-1""]","涉及权限模型,实施或修复后必须只读 reviewer 评审;不得在报告中输出完整账号或密码。"
OALAUNCH1-5,权限矩阵最终验收,"正式启用前复核员工、主管、财务、HR、CEO、管理员关键入口和 API 权限,重点验证普通员工不能越权。","普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口;各业务角色能访问必要入口;权限负例报告脱敏留存。","受影响流程:登录 -> 入口可见性 -> API 直连负例 -> 审计;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com OA_PERMISSION_MATRIX_REPORT_DIR=artifacts/qa-flow/oa-permission-matrix-20260523-magi node scripts/qa/check-oa-permission-matrix.mjs;OALAUNCH_PERMISSION_EVIDENCE=artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json bash scripts/qa/run-oa-final-launch-readiness.sh。","node|bash",DONE,DONE,DONE,"scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json","[""OALAUNCH1-1""]","2026-05-23 公网只读权限矩阵 PASS_PERMISSION_MATRIX_READY:7 个角色登录和角色匹配通过,40 个正向探针、24 个负向探针通过,导航入口和 API 直连负例均脱敏留存;未改权限模型,故无需 reviewer 修复复核。最终启用仍缺 OALAUNCH1-3/4/6/7、真实业务闭环、备份恢复、7 天稳定化和 Day 7 Go/No-Go。"
OALAUNCH1-6,备份恢复演练门禁,"正式启用前必须确认备份存在、可访问,并做一次受控恢复演练或影子库恢复验证,记录 RTO/RPO、负责人和证据路径。","备份介质或备份集合可定位;恢复演练不影响公网生产库;影子库或只读恢复验证通过;RTO/RPO 和负责人记录清楚。","受影响流程:备份检查 -> 影子恢复 -> 数据验证 -> Go/No-Go;验证步骤:按 OPS runbook 执行受控恢复演练,证据路径通过 OALAUNCH_BACKUP_EVIDENCE 提供给 run-oa-final-launch-readiness.sh。","manual|ops",TODO,TODO,TODO,"external|docs/trialrun/OPS7运维交接Runbook.md","[""OALAUNCH1-1""]","不得重置公网数据库;不得在未确认影响范围时执行 destructive restore;失败则暂停正式启用;回滚路径:只删除影子库/临时恢复环境并保留演练日志,公网生产库不做回滚动作。"
OALAUNCH1-7,数据治理培训与运营支持门禁,"最终启用前固定资料上传边界、档案保留、问题 owner、阻断处理、人工绕行和员工/审批人/财务/管理员培训清单。","手册包含可上传/不可上传边界;员工、审批人、财务、管理员知道首周操作;阻断、人工绕行和暂停标准可执行。","受影响流程:员工提交 -> 审批处理 -> 财务付款 -> 管理员日收口 -> 问题升级;验证步骤:管理员组织人工确认并在 Day 7 Go/No-Go 前记录接受、带 finding 接受或暂停。","manual",TODO,TODO,TODO,"docs/管理员手册.md;docs/员工快速上手.md","[""OALAUNCH1-2""]","合同正文、敏感报价、银行流水原件、凭据、Secret、未脱敏票面和完整本地路径不得进入仓库、聊天或报告。"
OALAUNCH1-8,OALAUNCH1 本地门禁与收口,"完成合同校验、脚本语法、文档审计、skill 校验和 diff 检查;如需公网证据,另行执行只读汇总脚本。","本地门禁通过;真实业务、权限、备份和 Day 7 Go/No-Go 未完成时保持 TODO/PAUSED,不伪造成 PASS。","受影响流程:最终启用准备 -> 本地静态门禁 -> 后续人工验收;验证步骤:validate_issues_csv.py;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue <csv> --profile general --json;bash -n scripts/qa/run-oa-final-launch-readiness.sh;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check。","python3|node|bash|git",DONE,DONE,DONE,"issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv;scripts/qa/run-oa-final-launch-readiness.sh;docs","[""OALAUNCH1-1"",""OALAUNCH1-2""]","本轮不部署;公网正式启用必须等 OALAUNCH1-3..7 真实证据和 Day 7 Go/No-Go;本地门禁已通过,check-process-contract general 已补 rollback 后通过。"
OALAUNCH1-9,采纳后最新只读证据回填,"用户采纳 OANEXT1 后,追加记录最新公网 OAOPS1 只读巡检和最终启用汇总引用;本项只同步证据,不关闭 OALAUNCH1-3..7。","OALAUNCH1 summary 指向最新 OAOPS1 报告;结论保持 PAUSED_MANUAL_CONFIRMATION;未完成的真实业务、权限、备份、7 天和 Go/No-Go 门禁继续 TODO。","受影响流程:公网只读巡检 -> 最终启用汇总 -> Go/No-Go 前证据查看;验证步骤:检查 artifacts/qa-flow/oa-real-use-daily-20260523-193128/summary.json 和 artifacts/qa-flow/oa-final-launch-readiness-20260523-193638/summary.json。","bash|manual",DONE,DONE,DONE,"artifacts/qa-flow/oa-real-use-daily-20260523-193128/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-193638/summary.json","[""OALAUNCH1-1""]","最新 OALAUNCH1 结论 PAUSED_MANUAL_CONFIRMATION,openCount=15;latestOaops=PASS_WITH_FINDINGS,finding 为重复登录 429/只读探针降级;不涉及业务写入、付款、企业微信写入或数据库恢复。"
OALAUNCH1-10,默认节流后的最新启用汇总,"按 OAOPS1 默认登录节流重新获取公网只读巡检证据,并重新汇总最终启用门禁;本项替代 OALAUNCH1-9 的临时 429 观察口径,但不关闭人工门禁。","latestOaops 为 PASS_OA_REAL_USE_DAILY_READY 且 findingCount=0;OALAUNCH1 结论仍为 PAUSED_MANUAL_CONFIRMATION;openCount=14,均为正式启用前必须人工或受控运维补齐的门禁。","受影响流程:公网只读巡检 -> OALAUNCH1 汇总 -> Day 7 Go/No-Go 准备;验证步骤:检查 artifacts/qa-flow/oa-real-use-daily-20260523-194013/summary.json 和 artifacts/qa-flow/oa-final-launch-readiness-20260523-194456/summary.json。","bash|manual",DONE,DONE,DONE,"artifacts/qa-flow/oa-real-use-daily-20260523-194013/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-194456/summary.json","[""OALAUNCH1-9""]","当前自动巡检无 finding;系统仍不能最终启用,原因是 OAOPS1-3/4/5/6、OALAUNCH1-3/4/5/6/7、7 天起始日、备份恢复、权限矩阵、真实业务证据和 Day 7 Go/No-Go 未完成。"
OALAUNCH1-11,权限矩阵证据回填后的启用汇总,"在 OALAUNCH1-5 权限矩阵自动验收通过后,重新执行最终启用汇总,确认权限门禁不再列为开放项,同时保留真实业务、备份、7 天和 Go/No-Go 未完成状态。","OALAUNCH_PERMISSION_EVIDENCE 指向权限矩阵报告;summary 结论仍为 PAUSED_MANUAL_CONFIRMATION;openCount=12,不再包含 OALAUNCH1-5 或 PERMISSION_MATRIX。","受影响流程:权限证据 -> 最终启用汇总 -> 剩余门禁清单;验证步骤:OALAUNCH_PERMISSION_EVIDENCE=artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json bash scripts/qa/run-oa-final-launch-readiness.sh;检查 artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json。","bash|manual",DONE,DONE,DONE,"artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json","[""OALAUNCH1-5""]","不伪造成最终启用:仍缺 OAOPS1-3/4/5/6、OALAUNCH1-3/4/6/7、SEVEN_DAY_STABILIZATION、BACKUP_RESTORE_DRILL、BUSINESS_ACCEPTANCE_EVIDENCE 和 DAY7_GO_NO_GO。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
4 OALAUNCH1-2 回写最终启用事实源和操作清单 更新管理员手册、员工快速上手、人工试运行报告、文档索引、事实源矩阵和项目 skill,固化最终启用前仍需补齐的门禁。 文档能检索到 OALAUNCH1、PASS_OA_LAUNCH_READY、7 天稳定化、备份恢复演练、权限矩阵、Go/No-Go 和 P2 冻结口径。 受影响流程:管理员/员工按手册执行上线前收口;验证步骤:rg -n "OALAUNCH1|PASS_OA_LAUNCH_READY|备份恢复|权限矩阵|Go/No-Go|7 天稳定化" docs .agents/skills/hyb-oa-delivery/SKILL.md。 rg|manual DONE DONE DONE docs/管理员手册.md;docs/员工快速上手.md;docs/trialrun/人工试运行报告.md;docs/文档索引.md;docs/文档事实源状态矩阵.md;.agents/skills/hyb-oa-delivery/SKILL.md ["OALAUNCH1-0"] 优先更新现有事实源,不新建孤立文档;rg、audit-docs 和 quick_validate 已通过,audit-docs 保留既有 unregistered docs files 24 warning。
5 OALAUNCH1-3 OAOPS1 真实业务闭环收口门禁 正式启用前必须补齐 OAOPS1-3、OAOPS1-4、OAOPS1-5、OAOPS1-6,不得只用自动巡检替代真实业务闭环。 至少 1 条真实报销 PDF OCR -> 审批 -> 付款批次 -> 银行回单 -> 已付款 -> AP 导出闭环;至少 1 条采购/费用审批并关联项目或合同;至少 1 条合同台账含归档位置和权限检查;每日问题登记机制可执行。 受影响流程:报销 -> 审批 -> 付款 -> AP;采购/费用 -> 审批 -> 项目/合同;合同台账 -> 归档 -> 权限;验证步骤:人工在公网 OA 完成真实业务样本,运行 run-oa-final-launch-readiness.sh 汇总 OAOPS1 状态。 manual|bash TODO TODO TODO external|issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv ["OALAUNCH1-1"] 真实票据、银行回单、合同正文和敏感报价不得进入仓库、聊天或自动化报告;阻断即建修复合同。
6 OALAUNCH1-4 7 天稳定化与每日巡检门禁 连续 7 天运行 OAOPS1 每日只读巡检并做人工日收口,最终汇总无 blocker。 每日 summary.json 可读;最终 7 天无不可解释 blocker;非阻断 finding 均有 owner、处理日期或接受理由。 受影响流程:每日巡检 -> 人工问题登记 -> Day 7 汇总;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh;Day 7 执行 OALAUNCH_STABILIZATION_START_DATE=YYYY-MM-DD run-oa-final-launch-readiness.sh。 bash|manual TODO TODO TODO artifacts/qa-flow/oa-real-use-daily-*|scripts/qa/run-oa-real-use-daily.sh ["OALAUNCH1-3"] 自动报告只说明运行状态;Day 7 Go/No-Go 仍需人工拍板;脚本必须按指定起始日期检查连续 7 天,不得用历史报告凑数。
7 OALAUNCH1-5 权限矩阵最终验收 正式启用前复核员工、主管、财务、HR、CEO、管理员关键入口和 API 权限,重点验证普通员工不能越权。 普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口;各业务角色能访问必要入口;权限负例报告脱敏留存。 受影响流程:登录 -> 入口可见性 -> API 直连负例 -> 审计;验证步骤:复用 OA-only smoke readiness、权限直连审计或人工浏览器验证,并将证据路径提供给 run-oa-final-launch-readiness.sh。 受影响流程:登录 -> 入口可见性 -> API 直连负例 -> 审计;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com OA_PERMISSION_MATRIX_REPORT_DIR=artifacts/qa-flow/oa-permission-matrix-20260523-magi node scripts/qa/check-oa-permission-matrix.mjs;OALAUNCH_PERMISSION_EVIDENCE=artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json bash scripts/qa/run-oa-final-launch-readiness.sh。 playwright|manual|node node|bash TODO DONE TODO DONE TODO DONE external|scripts/qa/check-oa-only-smoke-readiness.mjs scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json ["OALAUNCH1-1"] 涉及权限模型,实施或修复后必须只读 reviewer 评审;不得在报告中输出完整账号或密码。 2026-05-23 公网只读权限矩阵 PASS_PERMISSION_MATRIX_READY:7 个角色登录和角色匹配通过,40 个正向探针、24 个负向探针通过,导航入口和 API 直连负例均脱敏留存;未改权限模型,故无需 reviewer 修复复核。最终启用仍缺 OALAUNCH1-3/4/6/7、真实业务闭环、备份恢复、7 天稳定化和 Day 7 Go/No-Go。
8 OALAUNCH1-6 备份恢复演练门禁 正式启用前必须确认备份存在、可访问,并做一次受控恢复演练或影子库恢复验证,记录 RTO/RPO、负责人和证据路径。 备份介质或备份集合可定位;恢复演练不影响公网生产库;影子库或只读恢复验证通过;RTO/RPO 和负责人记录清楚。 受影响流程:备份检查 -> 影子恢复 -> 数据验证 -> Go/No-Go;验证步骤:按 OPS runbook 执行受控恢复演练,证据路径通过 OALAUNCH_BACKUP_EVIDENCE 提供给 run-oa-final-launch-readiness.sh。 manual|ops TODO TODO TODO external|docs/trialrun/OPS7运维交接Runbook.md ["OALAUNCH1-1"] 不得重置公网数据库;不得在未确认影响范围时执行 destructive restore;失败则暂停正式启用;回滚路径:只删除影子库/临时恢复环境并保留演练日志,公网生产库不做回滚动作。
9 OALAUNCH1-7 数据治理培训与运营支持门禁 最终启用前固定资料上传边界、档案保留、问题 owner、阻断处理、人工绕行和员工/审批人/财务/管理员培训清单。 手册包含可上传/不可上传边界;员工、审批人、财务、管理员知道首周操作;阻断、人工绕行和暂停标准可执行。 受影响流程:员工提交 -> 审批处理 -> 财务付款 -> 管理员日收口 -> 问题升级;验证步骤:管理员组织人工确认并在 Day 7 Go/No-Go 前记录接受、带 finding 接受或暂停。 manual TODO TODO TODO docs/管理员手册.md;docs/员工快速上手.md ["OALAUNCH1-2"] 合同正文、敏感报价、银行流水原件、凭据、Secret、未脱敏票面和完整本地路径不得进入仓库、聊天或报告。
10 OALAUNCH1-8 OALAUNCH1 本地门禁与收口 完成合同校验、脚本语法、文档审计、skill 校验和 diff 检查;如需公网证据,另行执行只读汇总脚本。 本地门禁通过;真实业务、权限、备份和 Day 7 Go/No-Go 未完成时保持 TODO/PAUSED,不伪造成 PASS。 受影响流程:最终启用准备 -> 本地静态门禁 -> 后续人工验收;验证步骤:validate_issues_csv.py;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue <csv> --profile general --json;bash -n scripts/qa/run-oa-final-launch-readiness.sh;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check。 python3|node|bash|git DONE DONE DONE issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv;scripts/qa/run-oa-final-launch-readiness.sh;docs ["OALAUNCH1-1","OALAUNCH1-2"] 本轮不部署;公网正式启用必须等 OALAUNCH1-3..7 真实证据和 Day 7 Go/No-Go;本地门禁已通过,check-process-contract general 已补 rollback 后通过。
11 OALAUNCH1-9 采纳后最新只读证据回填 用户采纳 OANEXT1 后,追加记录最新公网 OAOPS1 只读巡检和最终启用汇总引用;本项只同步证据,不关闭 OALAUNCH1-3..7。 OALAUNCH1 summary 指向最新 OAOPS1 报告;结论保持 PAUSED_MANUAL_CONFIRMATION;未完成的真实业务、权限、备份、7 天和 Go/No-Go 门禁继续 TODO。 受影响流程:公网只读巡检 -> 最终启用汇总 -> Go/No-Go 前证据查看;验证步骤:检查 artifacts/qa-flow/oa-real-use-daily-20260523-193128/summary.json 和 artifacts/qa-flow/oa-final-launch-readiness-20260523-193638/summary.json。 bash|manual DONE DONE DONE artifacts/qa-flow/oa-real-use-daily-20260523-193128/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-193638/summary.json ["OALAUNCH1-1"] 最新 OALAUNCH1 结论 PAUSED_MANUAL_CONFIRMATION,openCount=15;latestOaops=PASS_WITH_FINDINGS,finding 为重复登录 429/只读探针降级;不涉及业务写入、付款、企业微信写入或数据库恢复。
12 OALAUNCH1-10 默认节流后的最新启用汇总 按 OAOPS1 默认登录节流重新获取公网只读巡检证据,并重新汇总最终启用门禁;本项替代 OALAUNCH1-9 的临时 429 观察口径,但不关闭人工门禁。 latestOaops 为 PASS_OA_REAL_USE_DAILY_READY 且 findingCount=0;OALAUNCH1 结论仍为 PAUSED_MANUAL_CONFIRMATION;openCount=14,均为正式启用前必须人工或受控运维补齐的门禁。 受影响流程:公网只读巡检 -> OALAUNCH1 汇总 -> Day 7 Go/No-Go 准备;验证步骤:检查 artifacts/qa-flow/oa-real-use-daily-20260523-194013/summary.json 和 artifacts/qa-flow/oa-final-launch-readiness-20260523-194456/summary.json。 bash|manual DONE DONE DONE artifacts/qa-flow/oa-real-use-daily-20260523-194013/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-194456/summary.json ["OALAUNCH1-9"] 当前自动巡检无 finding;系统仍不能最终启用,原因是 OAOPS1-3/4/5/6、OALAUNCH1-3/4/5/6/7、7 天起始日、备份恢复、权限矩阵、真实业务证据和 Day 7 Go/No-Go 未完成。
13 OALAUNCH1-11 权限矩阵证据回填后的启用汇总 在 OALAUNCH1-5 权限矩阵自动验收通过后,重新执行最终启用汇总,确认权限门禁不再列为开放项,同时保留真实业务、备份、7 天和 Go/No-Go 未完成状态。 OALAUNCH_PERMISSION_EVIDENCE 指向权限矩阵报告;summary 结论仍为 PAUSED_MANUAL_CONFIRMATION;openCount=12,不再包含 OALAUNCH1-5 或 PERMISSION_MATRIX。 受影响流程:权限证据 -> 最终启用汇总 -> 剩余门禁清单;验证步骤:OALAUNCH_PERMISSION_EVIDENCE=artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json bash scripts/qa/run-oa-final-launch-readiness.sh;检查 artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json。 bash|manual DONE DONE DONE artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json ["OALAUNCH1-5"] 不伪造成最终启用:仍缺 OAOPS1-3/4/5/6、OALAUNCH1-3/4/6/7、SEVEN_DAY_STABILIZATION、BACKUP_RESTORE_DRILL、BUSINESS_ACCEPTANCE_EVIDENCE 和 DAY7_GO_NO_GO。
@@ -3,7 +3,7 @@
"OANEXT2-1","最新状态基线审视","梳理当前未完成项:OINV1-3/4/5/6、OALAUNCH1-3..7、OANEXT1-2..9、OAOPS1-3..6;识别哪些可自动化、哪些需人工/运维批准。","列出当前最高优先级:1) OINV1 批量 OCR 脱敏与验证;2) 权限矩阵自动负例;3) TEST/SMOKE 业务闭环;4) 真实业务人工闭环;5) 备份恢复;6) 7 天稳定化;7) Go/No-Go。","受影响流程:Issue 状态读取 -> 后续排序 -> 采纳决策;验证步骤:读取 OINV1/OALAUNCH1/OANEXT1/OAOPS1 CSV 中 TODO 行并核对最新提交。","python3|git","DONE","DONE","DONE","issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv;issues/2026-05-23_19-14-44-oa-next-work-adoption-plan-v1.csv;issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv","[""OANEXT2-0""]","当前证据:OAOPS1 最新 PASS_OA_REAL_USE_DAILY_READY;OINV1 找到报销候选 114 个、PDF 105 个;OALAUNCH1 仍 PAUSED_MANUAL_CONFIRMATION。"
"OANEXT2-2","第一优先级:批量 OCR 报告脱敏加固","若用户采纳,优先实施 OINV1-3:改造或新增 QA-only 批量 OCR 报告逻辑,避免真实发票号、销售方、金额、完整文件名、完整路径和票面原图进入日志、附件或 summary。","批量 OCR 报告只输出 alias、状态、耗时、字段命中布尔值、重复/已使用/失败可解释原因;隐私扫描通过;不改变生产业务 API/schema。","受影响流程:真实 PDF 样本 -> 公网上传 OCR -> 脱敏报告;验证步骤:node/ts 语法检查;Playwright 单票 smoke;报告隐私扫描;validate OINV1。","playwright|node|git","DONE","DONE","DONE","client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts;artifacts/qa-flow/live-smoke-readiness-20260523-205755;artifacts/qa-flow/oinv1-redacted-batch-smoke-20260523-205755","[""OANEXT2-1""]","用户选择 A;已完成脱敏加固和最小公网验证。回滚:恢复测试脚本改动;不影响生产 API/schema 或既有业务数据。"
"OANEXT2-3","第二优先级:代表小样本 PDF OCR smoke","在 OANEXT2-2 脱敏加固后,从 /tmp SAMPLE-PDF-xxx 别名池选取 2-3 张代表 PDF 报销候选样本执行公网 OCR smoke;默认不提交报销、不审批、不付款。105 张全量仅作为后续可选压力/覆盖专项,不作为启用前必跑门禁。","输出小样本脱敏 summary:总数、成功数、重复/已用数、失败/超时数、字段命中率和可解释失败分类;报告不含票面值、完整路径或原始 payload;发现不可解释失败进入修复合同。","受影响流程:PDF 样本池 -> 公网票据上传 -> OCR/重复终态 -> 脱敏统计;验证步骤:PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1 PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES=3 PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED=1 执行批量 OCR Playwright;隐私扫描。","playwright|bash","DONE","DONE","DONE","artifacts/qa-flow/oinv1-redacted-batch-api-coverage-stop-20260523-continue/summary.json;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts","[""OANEXT2-2""]","完成:公网小样本 planned=3、actual=2、stopReason=coverage_satisfied,覆盖可用终态和重复/相同发票终态;默认 API 轮询,UI 复核可通过 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui 单独执行。未提交报销、未审批、未付款。105 张全量继续作为后续可选压力/覆盖专项。"
"OANEXT2-4","第三优先级:权限矩阵自动负例","补齐 OALAUNCH1-5/OANEXT1-3:用 OA-only smoke 账号自动验证员工、主管、财务、HR、CEO、管理员的入口可用性和普通员工越权负例。","普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口、审计导出、高敏或冻结模块;角色必要入口可用;报告脱敏且可作为 OALAUNCH_PERMISSION_EVIDENCE。","受影响流程:多角色登录 -> 页面入口 -> API 直连负例 -> 权限报告;验证步骤:复用 check-oa-only-smoke-readiness、permission-direct-access 或新增 QA-only 权限矩阵脚本。","playwright|node","TODO","TODO","TODO","scripts/qa/check-oa-only-smoke-readiness.mjs;client/tests/e2e/permission-direct-access.spec.ts;docs/trialrun/权限直连审计记录.md","[""OANEXT2-1""]","涉及权限模型修复时必须启用只读 reviewer subagent;本项优先只读验证,不直接改权限代码。"
"OANEXT2-4","第三优先级:权限矩阵自动负例","补齐 OALAUNCH1-5/OANEXT1-3:用 OA-only smoke 账号自动验证员工、主管、财务、HR、CEO、管理员的入口可用性和普通员工越权负例。","普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口、审计导出、高敏或冻结模块;角色必要入口可用;报告脱敏且可作为 OALAUNCH_PERMISSION_EVIDENCE。","受影响流程:多角色登录 -> 页面入口 -> API 直连负例 -> 权限报告;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com OA_PERMISSION_MATRIX_REPORT_DIR=artifacts/qa-flow/oa-permission-matrix-20260523-magi node scripts/qa/check-oa-permission-matrix.mjs;隐私扫描。","node|bash","DONE","DONE","DONE","scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json","[""OANEXT2-1""]","完成:公网只读权限矩阵 PASS_PERMISSION_MATRIX_READY,覆盖 7 个 OA-only smoke 角色、40 个正向探针、24 个负向探针;员工/主管/普通财务越权拒绝,财务主管系统审计能力按既有权限模型视为授权,admin 以 portal 能力和导航验证为系统管理员且非业务审批/财务角色。报告脱敏;未改权限模型、未创建业务数据、未执行付款。"
"OANEXT2-5","第四优先级:TEST/SMOKE 业务闭环自动化","在不使用真实付款、不导入银行流水、不写 ERP 的前提下,准备用 TEST/SMOKE 标记自动跑 OA 主办域闭环:报销/OCR、审批、付款批次、银行回单附件、AP 导出、采购/费用、项目/合同关联。","自动化测试数据有 TEST/SMOKE marker;不含真实合同正文、敏感报价、银行流水原件或票面原图;可验证流程技术闭环,但不替代真实业务人工验收。","受影响流程:报销 -> 审批 -> 付款批次 -> 回单 -> AP;采购/费用 -> 项目/合同;验证步骤:复用 reimbursement-payment-batch-flow、run-oa-real-use-daily 或新增 QA-only flow;报告脱敏。","playwright|bash","TODO","TODO","TODO","client/tests/e2e/reimbursement-payment-batch-flow.spec.ts;scripts/qa/run-oa-real-use-daily.sh;artifacts/qa-flow","[""OANEXT2-2"",""OANEXT2-4""]","执行前需要用户确认是否允许公网创建 TEST/SMOKE 业务测试数据。实际付款仍由线下银行渠道完成,自动化不得触发真实支付。"
"OANEXT2-6","第五优先级:真实业务人工闭环证据包","补齐 OAOPS1-3/4/5/6 和 OALAUNCH1-3:用 1 条真实报销、1 条采购/费用、1 条合同台账和每日问题登记形成正式启用前人工证据。","真实报销完成 OCR、提交、审批、付款批次、回单、已付款和 AP 导出;采购/费用完成审批并关联项目或合同;合同台账有编号、负责人、归档位置和权限检查;问题登记机制可执行。","受影响流程:真实业务 -> OA 记录 -> 财务/审计 -> 最终启用汇总;验证步骤:人工在公网 OA 操作;运行 run-oa-final-launch-readiness.sh 并提供 OALAUNCH_BUSINESS_EVIDENCE。","manual|bash","TODO","TODO","TODO","issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv","[""OANEXT2-5""]","真实票据、合同正文、敏感报价、银行流水原件和未脱敏票面不得进入仓库/聊天/自动化报告。"
"OANEXT2-7","第六优先级:备份恢复演练方案与执行门禁","补齐 OALAUNCH1-6/OANEXT1-4:规划并执行影子库或隔离恢复验证,确认备份可定位、可访问、可恢复,不影响公网生产库。","有备份集合/介质引用、恢复演练证据、RTO/RPO、负责人、关键表/附件/登录/报销详情/AP 抽样结果;失败则暂停正式启用。","受影响流程:备份检查 -> 影子恢复 -> 数据验证 -> Go/No-Go;验证步骤:按 OPS runbook 执行;证据路径传入 OALAUNCH_BACKUP_EVIDENCE。","manual|ops","TODO","TODO","TODO","docs/trialrun/OPS7运维交接Runbook.md;docs/ops/非功能基线.md;docs/需求-资产管理规约.md","[""OANEXT2-6""]","必须人工批准范围;不得重置公网数据库,不得 destructive restore。回滚/清理:仅删除影子库、临时恢复目录和临时凭据,公网生产库不回滚不覆盖。涉及恢复/权限修复后需只读 reviewer subagent。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
3 OANEXT2-1 最新状态基线审视 梳理当前未完成项:OINV1-3/4/5/6、OALAUNCH1-3..7、OANEXT1-2..9、OAOPS1-3..6;识别哪些可自动化、哪些需人工/运维批准。 列出当前最高优先级:1) OINV1 批量 OCR 脱敏与验证;2) 权限矩阵自动负例;3) TEST/SMOKE 业务闭环;4) 真实业务人工闭环;5) 备份恢复;6) 7 天稳定化;7) Go/No-Go。 受影响流程:Issue 状态读取 -> 后续排序 -> 采纳决策;验证步骤:读取 OINV1/OALAUNCH1/OANEXT1/OAOPS1 CSV 中 TODO 行并核对最新提交。 python3|git DONE DONE DONE issues/2026-05-23_20-24-42-oa-invoice-automation-hardening-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv;issues/2026-05-23_19-14-44-oa-next-work-adoption-plan-v1.csv;issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv ["OANEXT2-0"] 当前证据:OAOPS1 最新 PASS_OA_REAL_USE_DAILY_READY;OINV1 找到报销候选 114 个、PDF 105 个;OALAUNCH1 仍 PAUSED_MANUAL_CONFIRMATION。
4 OANEXT2-2 第一优先级:批量 OCR 报告脱敏加固 若用户采纳,优先实施 OINV1-3:改造或新增 QA-only 批量 OCR 报告逻辑,避免真实发票号、销售方、金额、完整文件名、完整路径和票面原图进入日志、附件或 summary。 批量 OCR 报告只输出 alias、状态、耗时、字段命中布尔值、重复/已使用/失败可解释原因;隐私扫描通过;不改变生产业务 API/schema。 受影响流程:真实 PDF 样本 -> 公网上传 OCR -> 脱敏报告;验证步骤:node/ts 语法检查;Playwright 单票 smoke;报告隐私扫描;validate OINV1。 playwright|node|git DONE DONE DONE client/tests/e2e/manual/live-invoice-ocr.spec.ts;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts;artifacts/qa-flow/live-smoke-readiness-20260523-205755;artifacts/qa-flow/oinv1-redacted-batch-smoke-20260523-205755 ["OANEXT2-1"] 用户选择 A;已完成脱敏加固和最小公网验证。回滚:恢复测试脚本改动;不影响生产 API/schema 或既有业务数据。
5 OANEXT2-3 第二优先级:代表小样本 PDF OCR smoke 在 OANEXT2-2 脱敏加固后,从 /tmp SAMPLE-PDF-xxx 别名池选取 2-3 张代表 PDF 报销候选样本执行公网 OCR smoke;默认不提交报销、不审批、不付款。105 张全量仅作为后续可选压力/覆盖专项,不作为启用前必跑门禁。 输出小样本脱敏 summary:总数、成功数、重复/已用数、失败/超时数、字段命中率和可解释失败分类;报告不含票面值、完整路径或原始 payload;发现不可解释失败进入修复合同。 受影响流程:PDF 样本池 -> 公网票据上传 -> OCR/重复终态 -> 脱敏统计;验证步骤:PLAYWRIGHT_MANUAL=1 PLAYWRIGHT_OCR_LIVE=1 PLAYWRIGHT_OCR_ALLOW_DUPLICATE=1 PLAYWRIGHT_OCR_BATCH_MAX_SAMPLES=3 PLAYWRIGHT_OCR_BATCH_STOP_WHEN_COVERED=1 执行批量 OCR Playwright;隐私扫描。 playwright|bash DONE DONE DONE artifacts/qa-flow/oinv1-redacted-batch-api-coverage-stop-20260523-continue/summary.json;client/tests/e2e/manual/live-invoice-ocr-batch.spec.ts;client/tests/e2e/helpers/liveInvoiceOcr.ts ["OANEXT2-2"] 完成:公网小样本 planned=3、actual=2、stopReason=coverage_satisfied,覆盖可用终态和重复/相同发票终态;默认 API 轮询,UI 复核可通过 PLAYWRIGHT_OCR_BATCH_TRANSPORT=ui 单独执行。未提交报销、未审批、未付款。105 张全量继续作为后续可选压力/覆盖专项。
6 OANEXT2-4 第三优先级:权限矩阵自动负例 补齐 OALAUNCH1-5/OANEXT1-3:用 OA-only smoke 账号自动验证员工、主管、财务、HR、CEO、管理员的入口可用性和普通员工越权负例。 普通员工不能访问财务/AP、合同敏感附件、他人审批、管理入口、审计导出、高敏或冻结模块;角色必要入口可用;报告脱敏且可作为 OALAUNCH_PERMISSION_EVIDENCE。 受影响流程:多角色登录 -> 页面入口 -> API 直连负例 -> 权限报告;验证步骤:复用 check-oa-only-smoke-readiness、permission-direct-access 或新增 QA-only 权限矩阵脚本。 受影响流程:多角色登录 -> 页面入口 -> API 直连负例 -> 权限报告;验证步骤:LIVE_SMOKE_BASE_URL=https://oa.hyinda.com OA_PERMISSION_MATRIX_REPORT_DIR=artifacts/qa-flow/oa-permission-matrix-20260523-magi node scripts/qa/check-oa-permission-matrix.mjs;隐私扫描。 playwright|node node|bash TODO DONE TODO DONE TODO DONE scripts/qa/check-oa-only-smoke-readiness.mjs;client/tests/e2e/permission-direct-access.spec.ts;docs/trialrun/权限直连审计记录.md scripts/qa/check-oa-permission-matrix.mjs;artifacts/qa-flow/oa-permission-matrix-20260523-magi/summary.json;artifacts/qa-flow/oa-final-launch-readiness-20260523-220358/summary.json ["OANEXT2-1"] 涉及权限模型修复时必须启用只读 reviewer subagent;本项优先只读验证,不直接改权限代码。 完成:公网只读权限矩阵 PASS_PERMISSION_MATRIX_READY,覆盖 7 个 OA-only smoke 角色、40 个正向探针、24 个负向探针;员工/主管/普通财务越权拒绝,财务主管系统审计能力按既有权限模型视为授权,admin 以 portal 能力和导航验证为系统管理员且非业务审批/财务角色。报告脱敏;未改权限模型、未创建业务数据、未执行付款。
7 OANEXT2-5 第四优先级:TEST/SMOKE 业务闭环自动化 在不使用真实付款、不导入银行流水、不写 ERP 的前提下,准备用 TEST/SMOKE 标记自动跑 OA 主办域闭环:报销/OCR、审批、付款批次、银行回单附件、AP 导出、采购/费用、项目/合同关联。 自动化测试数据有 TEST/SMOKE marker;不含真实合同正文、敏感报价、银行流水原件或票面原图;可验证流程技术闭环,但不替代真实业务人工验收。 受影响流程:报销 -> 审批 -> 付款批次 -> 回单 -> AP;采购/费用 -> 项目/合同;验证步骤:复用 reimbursement-payment-batch-flow、run-oa-real-use-daily 或新增 QA-only flow;报告脱敏。 playwright|bash TODO TODO TODO client/tests/e2e/reimbursement-payment-batch-flow.spec.ts;scripts/qa/run-oa-real-use-daily.sh;artifacts/qa-flow ["OANEXT2-2","OANEXT2-4"] 执行前需要用户确认是否允许公网创建 TEST/SMOKE 业务测试数据。实际付款仍由线下银行渠道完成,自动化不得触发真实支付。
8 OANEXT2-6 第五优先级:真实业务人工闭环证据包 补齐 OAOPS1-3/4/5/6 和 OALAUNCH1-3:用 1 条真实报销、1 条采购/费用、1 条合同台账和每日问题登记形成正式启用前人工证据。 真实报销完成 OCR、提交、审批、付款批次、回单、已付款和 AP 导出;采购/费用完成审批并关联项目或合同;合同台账有编号、负责人、归档位置和权限检查;问题登记机制可执行。 受影响流程:真实业务 -> OA 记录 -> 财务/审计 -> 最终启用汇总;验证步骤:人工在公网 OA 操作;运行 run-oa-final-launch-readiness.sh 并提供 OALAUNCH_BUSINESS_EVIDENCE。 manual|bash TODO TODO TODO issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv;issues/2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv ["OANEXT2-5"] 真实票据、合同正文、敏感报价、银行流水原件和未脱敏票面不得进入仓库/聊天/自动化报告。
9 OANEXT2-7 第六优先级:备份恢复演练方案与执行门禁 补齐 OALAUNCH1-6/OANEXT1-4:规划并执行影子库或隔离恢复验证,确认备份可定位、可访问、可恢复,不影响公网生产库。 有备份集合/介质引用、恢复演练证据、RTO/RPO、负责人、关键表/附件/登录/报销详情/AP 抽样结果;失败则暂停正式启用。 受影响流程:备份检查 -> 影子恢复 -> 数据验证 -> Go/No-Go;验证步骤:按 OPS runbook 执行;证据路径传入 OALAUNCH_BACKUP_EVIDENCE。 manual|ops TODO TODO TODO docs/trialrun/OPS7运维交接Runbook.md;docs/ops/非功能基线.md;docs/需求-资产管理规约.md ["OANEXT2-6"] 必须人工批准范围;不得重置公网数据库,不得 destructive restore。回滚/清理:仅删除影子库、临时恢复目录和临时凭据,公网生产库不回滚不覆盖。涉及恢复/权限修复后需只读 reviewer subagent。
+625
查看文件
@@ -0,0 +1,625 @@
#!/usr/bin/env node
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
const baseUrl = (process.env.LIVE_SMOKE_BASE_URL || "https://oa.hyinda.com").trim().replace(/\/$/, "");
const reportDir = process.env.OA_PERMISSION_MATRIX_REPORT_DIR
|| path.resolve(`artifacts/qa-flow/oa-permission-matrix-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`);
const loginCooldownMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_LOGIN_COOLDOWN_MS || "1500", 10) || 0);
const retryAfterRateLimitMs = Math.max(0, Number.parseInt(process.env.OA_PERMISSION_MATRIX_RATE_LIMIT_WAIT_MS || "65000", 10) || 0);
const ROLE_TARGETS = [
{
key: "employee",
identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_EMPLOYEE_PASSWORD",
expectedRoles: ["employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
{ path: "data.roleCapabilities.canUseFinance", equals: false },
{ path: "data.roleCapabilities.canManageSystem", equals: false },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "own-invoices", path: "/api/invoice-pool?ownerScope=own&pageSize=5", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/portal", "/reimbursements", "/assets"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
},
},
],
negative: [
{ id: "budget", path: "/api/budgets", statuses: [403] },
{ id: "ap", path: "/api/ap-operations", statuses: [403, 409] },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [403] },
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
],
},
{
key: "manager",
identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_MANAGER_PASSWORD",
expectedRoles: ["manager", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
{ path: "data.roleCapabilities.canUseFinance", equals: false },
{ path: "data.roleCapabilities.canManageSystem", equals: false },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "workflow", path: "/api/workflow/tasks?page=1&pageSize=5", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/workflow/tasks"],
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
},
},
],
negative: [
{ id: "ap", path: "/api/ap-operations", statuses: [403, 409] },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
],
},
{
key: "finance",
identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_PASSWORD",
expectedRoles: ["finance", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
{ path: "data.roleCapabilities.canUseFinance", equals: true },
{ path: "data.roleCapabilities.canManageSystem", equals: false },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] },
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/invoice-pool", "/payment-batches", "/ap-operations"],
hidden: ["/audit-logs", "/system/settings", "/system/feature-flags", "/system/integrations/erp"],
},
},
],
negative: [
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [403] },
],
},
{
key: "finance_mgr",
identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_MGR_PASSWORD",
expectedRoles: ["finance", "finance_mgr", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
{ path: "data.roleCapabilities.canUseFinance", equals: true },
{ path: "data.roleCapabilities.canManageSystem", equals: true },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "all-invoices", path: "/api/invoice-pool?ownerScope=all&pageSize=5", statuses: [200] },
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [200, 409] },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [200, 409] },
{ id: "erp-status", path: "/api/integrations/erp/master-data/status", statuses: [200] },
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] },
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] },
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/invoice-pool", "/payment-batches", "/ap-operations", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
hidden: [],
},
},
],
negative: [],
},
{
key: "hr",
identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_HR_PASSWORD",
expectedRoles: ["hr", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
{ path: "data.roleCapabilities.canUseFinance", equals: false },
{ path: "data.roleCapabilities.canManageSystem", equals: true },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "wecom-archive", path: "/api/wecom/approval-archive?page=1&pageSize=5&scope=all", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/people", "/system/settings"],
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
},
},
],
negative: [
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?pageSize=5", statuses: [403, 409] },
{ id: "payment-batches", path: "/api/payment-batches?pageSize=5", statuses: [403, 409] },
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [403] },
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
],
},
{
key: "ceo",
identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_CEO_PASSWORD",
expectedRoles: ["ceo", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: true },
{ path: "data.roleCapabilities.canUseFinance", equals: false },
{ path: "data.roleCapabilities.canManageSystem", equals: false },
{ path: "data.roleCapabilities.isAdminOnly", equals: false },
],
},
{ id: "dashboard", path: "/api/dashboard/expense-overview", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/workflow/tasks", "/budgets"],
hidden: ["/ap-operations", "/payment-batches", "/audit-logs", "/system/feature-flags", "/system/integrations/erp"],
},
},
],
negative: [
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [403] },
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [403] },
],
},
{
key: "admin",
identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_ADMIN_PASSWORD",
expectedRoles: ["admin", "employee"],
positive: [
{ id: "me", path: "/api/me", statuses: [200] },
{
id: "portal",
path: "/api/portal/home",
statuses: [200],
dataExpectations: [
{ path: "data.roleCapabilities.canApproveTasks", equals: false },
{ path: "data.roleCapabilities.canUseFinance", equals: false },
{ path: "data.roleCapabilities.canManageSystem", equals: true },
{ path: "data.roleCapabilities.isAdminOnly", equals: true },
],
},
{ id: "users", path: "/api/users?page=1&pageSize=1", statuses: [200] },
{ id: "audit-logs", path: "/api/audit-logs?pageSize=5", statuses: [200] },
{ id: "notification-deliveries", path: "/api/notifications/deliveries?limit=5", statuses: [200] },
{ id: "system-feature-flags", path: "/api/system/feature-flags", statuses: [200] },
{ id: "assets", path: "/api/assets?pageSize=5", statuses: [200] },
{
id: "navigation",
path: "/api/system/navigation",
statuses: [200],
navigationExpectations: {
visible: ["/audit-logs", "/system/settings", "/system/feature-flags"],
hidden: ["/workflow/tasks", "/ap-operations", "/payment-batches"],
},
},
],
negative: [],
},
];
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function maskIdentifier(value) {
const text = String(value || "").trim();
if (!text) return null;
if (text.includes("@")) {
const [local, domain] = text.split("@");
const suffix = String(domain || "").split(".").at(-1) || "***";
return `${local.slice(0, 2) || "*"}***@***.${suffix}`;
}
if (text.length <= 4) return "***";
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
function sanitizeError(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/authorization:\s*bearer\s+[^\s]+/gi, "authorization: bearer ***")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 160);
}
function summarizeData(json) {
const data = json?.data;
if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null };
if (!data || typeof data !== "object") return {};
if (data.sections && typeof data.sections === "object") {
const hrefs = collectNavigationHrefs(json);
return { navigationItemCount: hrefs.length };
}
const summary = {};
for (const [key, value] of Object.entries(data)) {
if (key === "roleCodes" && Array.isArray(value)) {
summary.roleCodes = value.filter((item) => typeof item === "string").sort();
continue;
}
if (key === "permissionCodes" && Array.isArray(value)) {
summary.permissionCount = value.length;
continue;
}
if (key === "roleCapabilities" && value && typeof value === "object") {
summary.roleCapabilities = Object.fromEntries(
Object.entries(value)
.filter(([, capabilityValue]) => typeof capabilityValue === "boolean")
.sort(([left], [right]) => left.localeCompare(right)),
);
continue;
}
if (typeof value === "number" || typeof value === "boolean") summary[key] = value;
if (/^(status|mode|conclusion|ownerScope)$/i.test(key) && typeof value === "string" && /^[a-z0-9_/-]{1,60}$/i.test(value)) {
summary[key] = value;
}
}
return summary;
}
function getByPath(value, dottedPath) {
return String(dottedPath || "")
.split(".")
.filter(Boolean)
.reduce((current, key) => {
if (!current || typeof current !== "object") return undefined;
return current[key];
}, value);
}
function evaluateDataExpectations(json, expectations = []) {
return expectations.map((expectation) => {
const actual = getByPath(json, expectation.path);
const pass = actual === expectation.equals;
return {
path: expectation.path,
status: pass ? "PASS" : "FAIL",
expected: expectation.equals,
actual: typeof actual === "boolean" || typeof actual === "number" || typeof actual === "string" || actual === null
? actual
: typeof actual,
};
});
}
function collectNavigationHrefs(json) {
const sections = json?.data?.sections;
if (!sections || typeof sections !== "object") return [];
const hrefs = [];
for (const sectionItems of Object.values(sections)) {
if (!Array.isArray(sectionItems)) continue;
for (const item of sectionItems) {
if (item && typeof item === "object" && typeof item.href === "string") {
hrefs.push(item.href);
}
}
}
return Array.from(new Set(hrefs)).sort();
}
function evaluateNavigationExpectations(json, expectations) {
if (!expectations) return [];
const hrefs = new Set(collectNavigationHrefs(json));
const visible = (expectations.visible || []).map((href) => ({
type: "visible",
href,
status: hrefs.has(href) ? "PASS" : "FAIL",
}));
const hidden = (expectations.hidden || []).map((href) => ({
type: "hidden",
href,
status: hrefs.has(href) ? "FAIL" : "PASS",
}));
return [...visible, ...hidden];
}
function collectSensitiveKeyHits(value, currentPath = "data", hits = []) {
if (hits.length >= 12) return hits;
if (Array.isArray(value)) {
value.slice(0, 5).forEach((item, index) => collectSensitiveKeyHits(item, `${currentPath}[${index}]`, hits));
return hits;
}
if (!value || typeof value !== "object") return hits;
for (const [key, child] of Object.entries(value)) {
if (/(password|token|secret|encodingaeskey|privatekey)/i.test(key)) {
hits.push(`${currentPath}.${key}`);
if (hits.length >= 12) return hits;
}
collectSensitiveKeyHits(child, `${currentPath}.${key}`, hits);
}
return hits;
}
async function fetchJson(url, init = {}) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(20_000),
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json, text: text.slice(0, 120) };
} catch (error) {
return { ok: false, status: 0, error: sanitizeError(error instanceof Error ? error.message : String(error)) };
}
}
async function login(target, attempt = 0) {
const identifier = process.env[target.identifierEnv]?.trim();
const password = process.env[target.passwordEnv]?.trim();
if (!identifier || !password) {
return {
status: "SKIP",
reason: "missing_credentials",
maskedIdentifier: maskIdentifier(identifier),
};
}
if (loginCooldownMs > 0) await wait(loginCooldownMs);
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": `oa-permission-matrix-${target.key}`,
},
body: JSON.stringify({ identifier, password }),
});
const code = result.json?.error?.code ?? null;
if (result.status === 429 && attempt === 0 && retryAfterRateLimitMs > 0) {
await wait(retryAfterRateLimitMs);
return login(target, attempt + 1);
}
const accessToken = result.json?.data?.accessToken;
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
return {
status: "FAIL",
reason: `login_http_${result.status}`,
code,
maskedIdentifier: maskIdentifier(identifier),
};
}
return {
status: "PASS",
accessToken,
maskedIdentifier: maskIdentifier(identifier),
};
}
async function probe(accessToken, probe, kind) {
const response = await fetchJson(`${baseUrl}${probe.path}`, {
headers: {
accept: "application/json",
authorization: `Bearer ${accessToken}`,
},
});
const expected = probe.statuses.includes(response.status);
const dataExpectations = response.ok ? evaluateDataExpectations(response.json, probe.dataExpectations) : [];
const navigationExpectations = response.ok ? evaluateNavigationExpectations(response.json, probe.navigationExpectations) : [];
const dataExpectationFailed = dataExpectations.some((item) => item.status === "FAIL");
const navigationExpectationFailed = navigationExpectations.some((item) => item.status === "FAIL");
const sensitiveKeyHits = response.ok ? collectSensitiveKeyHits(response.json?.data).slice(0, 12) : [];
return {
id: probe.id,
kind,
status: expected && !dataExpectationFailed && !navigationExpectationFailed && sensitiveKeyHits.length === 0 ? "PASS" : "FAIL",
httpStatus: response.status,
expectedStatuses: probe.statuses,
dataExpectations,
navigationExpectations,
sensitiveKeyHits,
summary: response.ok ? summarizeData(response.json) : {
code: response.json?.error?.code ?? null,
message: sanitizeError(response.json?.error?.message ?? response.error ?? response.text),
},
};
}
async function runTarget(target) {
const loginState = await login(target);
const result = {
key: target.key,
expectedRoles: target.expectedRoles,
maskedIdentifier: loginState.maskedIdentifier,
login: {
status: loginState.status,
reason: loginState.reason || null,
code: loginState.code || null,
},
roleMatch: "SKIP",
actualRoles: [],
probes: [],
};
if (loginState.status !== "PASS") {
return result;
}
for (const item of target.positive) {
const probeResult = await probe(loginState.accessToken, item, "positive");
result.probes.push(probeResult);
if (item.id === "me" && probeResult.status === "PASS") {
const actualRoles = probeResult.summary?.roleCodes || [];
const missingRoles = target.expectedRoles.filter((role) => !actualRoles.includes(role));
result.roleMatch = missingRoles.length ? "FAIL" : "PASS";
result.actualRoles = actualRoles;
result.missingRoles = missingRoles;
}
}
for (const item of target.negative) {
result.probes.push(await probe(loginState.accessToken, item, "negative"));
}
return result;
}
function collectIssues(results) {
return results.flatMap((target) => {
const issues = [];
if (target.login.status === "FAIL") issues.push({ key: target.key, source: "login", status: "FAIL", code: target.login.code || target.login.reason });
if (target.roleMatch === "FAIL") issues.push({ key: target.key, source: "roles", status: "FAIL", missingRoles: target.missingRoles });
for (const probeResult of target.probes) {
if (probeResult.status === "FAIL") {
issues.push({
key: target.key,
source: probeResult.id,
kind: probeResult.kind,
status: "FAIL",
httpStatus: probeResult.httpStatus,
expectedStatuses: probeResult.expectedStatuses,
code: probeResult.summary?.code ?? null,
failedDataExpectations: probeResult.dataExpectations?.filter((item) => item.status === "FAIL") || [],
failedNavigationExpectations: probeResult.navigationExpectations?.filter((item) => item.status === "FAIL") || [],
sensitiveKeyHits: probeResult.sensitiveKeyHits || [],
});
}
}
return issues;
});
}
function writeReports(summary) {
mkdirSync(reportDir, { recursive: true });
writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf-8");
const lines = [
"# OA 权限矩阵脱敏自动验证报告",
"",
`生成时间:${summary.checkedAt}`,
`结论:${summary.status}`,
"",
"## 统计",
"",
`- 角色数:${summary.counts.roles}`,
`- 正向探针:${summary.counts.positiveProbes}`,
`- 负向探针:${summary.counts.negativeProbes}`,
`- 问题数:${summary.issues.length}`,
"",
"## 角色结果",
"",
"| 角色 | 登录 | 角色匹配 | 探针通过/总数 |",
"| --- | --- | --- | ---: |",
...summary.results.map((item) => {
const passed = item.probes.filter((probeResult) => probeResult.status === "PASS").length;
return `| ${item.key} | ${item.login.status} | ${item.roleMatch} | ${passed}/${item.probes.length} |`;
}),
"",
"## 说明",
"",
"- 本报告只输出角色、HTTP 状态、脱敏账号和摘要计数,不输出密码、token、Secret、完整账号、合同正文、票据内容或原始 payload。",
"- 409 表示功能门禁关闭且未泄露数据;对财务/AP/付款等冻结或受控功能可作为可解释拒绝。",
];
writeFileSync(path.join(reportDir, "summary.md"), `${lines.join("\n")}\n`, "utf-8");
}
async function main() {
const results = [];
for (const target of ROLE_TARGETS) {
results.push(await runTarget(target));
}
const issues = collectIssues(results);
const skipped = results.filter((item) => item.login.status === "SKIP");
let status = "PASS_PERMISSION_MATRIX_READY";
if (issues.length) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (skipped.length) {
status = "PASS_WITH_FINDINGS";
}
const summary = {
issueId: "OANEXT2-4",
status,
checkedAt: new Date().toISOString(),
baseUrl,
privacy: "redacted role/status/count evidence only; no password, token, Secret, full account, invoice, contract, attachment, or raw payload",
counts: {
roles: results.length,
positiveProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "positive").length, 0),
negativeProbes: results.reduce((sum, item) => sum + item.probes.filter((probeResult) => probeResult.kind === "negative").length, 0),
},
issues,
results,
};
writeReports(summary);
console.log(`[oa-permission-matrix] status=${summary.status} report=${reportDir}`);
if (issues.length) {
process.exitCode = 1;
}
}
main().catch((error) => {
console.error(`[oa-permission-matrix][ERROR] ${sanitizeError(error instanceof Error ? error.message : String(error))}`);
process.exit(1);
});