OSMOKE1 修复 OA-only smoke 账号治理

这个提交包含在:
2026-05-23 14:46:14 +08:00
父节点 88b06346da
当前提交 5a64809004
共修改 15 个文件,包含 1209 行新增和 13 行删除
+1
查看文件
@@ -27,6 +27,7 @@ Read only what is relevant:
- OAOPS1 is the current post-WeCom lane for OA-primary maturation: WeCom check-in, missed punch, outing, leave, and overtime are frozen for now as WeCom-owned HR/attendance with OA archive/audit. The active route returns to OA-primary real use for a 7-day small-team stabilization window. Public OA may hold real business data for reimbursement/PDF OCR, approval, procurement/expense, contract registry, projects, payment batches, bank receipt attachments, AP/accounting handoff, mobile/WeCom entry, and audit. Use `scripts/qa/run-oa-real-use-daily.sh` for daily read-only checks; real business writes are manual in public OA. Do not upload confidential contract bodies, sensitive quotes, cost detail, bank statement originals, raw invoice images, or secrets to repo docs, chat, or reports. Keep image OCR, complex BPM, budgets, ERP, bank-statement import, real payment-provider integration, WeCom HR deep mirror, emergency takeover, recovery writeback, and RuleOps frozen unless a new Issue CSV explicitly approves them.
- WAPC2FLIVE supersedes the WAPC2F dry-run gap for OA-POC samples: after user approval, the runner was corrected to the official `template_name` + `property` controls create-template payload, deployed with `SYNC_ENV=0`, and the public live report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` reached `PASS_FIXTURE_CREATED`; leave/missed-punch OA-POC templates and low-risk test approvals were created and retained for audit, with WAPC2 auto-rerun `PASS_APPROVAL_POC`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3/WACP3R has added and deployed `npm run check:wecom-approval-compensation-poc`, `POST /cgi-bin/oa/getapprovalinfo` read support, and automatic template discovery by `SMOKE002` creator; WREAD1 deploy reran WACP3R at `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json` and then generated `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` as `PAUSED_MANUAL_CONFIRMATION`. WREAD1F then fixed duplicate OA-POC template names by adding timestamp uniqueness and created fresh leave/missed-punch approvals at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`; final WREAD1 manual report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`. This clears leave/missed-punch readability only; mirror data model, minimum archive, emergency mode, writeback, RuleOps, and outing/overtime sample decisions remain later gates.
- ASSET1 is the current unified asset registry lane after OAOPS1: reuse the existing asset module for fixed assets plus electronic document/system/repository/backup asset metadata. Number families are `FA-YYYY-NNNNNN`, `EA-DOC-YYYY-NNNNNN`, `EA-SYS-YYYY-NNNNNN`, `EA-REPO-YYYY-NNNNNN`, and `EA-BKP-YYYY-NNNNNN`; `VER-NNNNN` is historical lookup only. `EA-ACC` is deferred: never store passwords, token, Secret, private keys, source bundles, full config, confidential document bodies, complete local paths, sensitive quotes, bank statement originals, or raw invoice images in OA, repo docs, chat, or reports. Git remains the source of truth for code; OA only stores repository metadata and responsibility. Historical company directory imports may only use redacted metadata from `00_保密索引`, default dry-run, with sensitive text/path guards.
- OSMOKE1 is the current OA-only smoke account governance lane after ASSET1D/OAOPS1 blockers: do not request more Enterprise WeChat accounts just to test OA-primary reimbursement/assets/finance/AP/contracts/permissions. Enterprise WeChat smoke accounts remain for HR/attendance evidence; admin/finance/hr/ceo OA regression accounts are OA-only smoke accounts and may be active in OA without WeCom binding. Use `npm run repair:oa-only-smoke` from `server/` for dry-run first; apply requires `--apply` plus `OA_ONLY_SMOKE_REPAIR_APPLY=1`, must not reset passwords, print secrets, affect real employees, or write WeCom. `audit-wecom-oa-user-consistency` classifies env-listed, explicit allowlisted, or `SMOKE*`/`smoke-*`/test-named OA-only smoke accounts as `oaOnlySmokeExempted` so they are not archived as missing in WeCom; `run-oa-real-use-daily.sh` includes OA-only smoke readiness before asset/finance UI automation.
- WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation, WARCH2 supplies the formal read-only history query surface, and WROAD2 supplies controlled ops daily checks. Do not start deep mirror until WROAD2-CLOSEOUT, WROAD2-BIZ1, WAPPR1, WRULE1, WAUTO1/WEXT1LD, WFULLSMOKE1, WTCLOSE2, WEXT1-FORMAL, WREAL1 question gates or explicit approval, and the mirror stage's own contract are complete.
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
- For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`.
+1
查看文件
@@ -9,6 +9,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
- R6 首轮小范围试运行已通过;范围是报销、请假、采购、审批、企业微信入口/通知、财务付款/导出。
- 2026-05-22 OAOPS1 已切换为当前主线:企微打卡、补卡、外出、请假等 HR/考勤先冻结在企业微信主办 + OA 留存/审计完成态;OA 系统进入 7 天小团队全员真实使用稳定化。公网 OA 允许录入真实业务数据,首批覆盖报销/PDF OCR、审批、采购/费用、合同台账、项目、付款批次、银行回单、AP 台账、代账交接、移动/企微入口和审计;新增 `scripts/qa/run-oa-real-use-daily.sh` 做每日只读巡检。阻断即修,非阻断进入反馈池;图片 OCR、复杂 BPM、预算、ERP、银行流水导入、真实支付直连、企微 HR 深镜像、应急接管、恢复回写和 RuleOps 继续冻结。
- 2026-05-23 OSMOKE1 固定 OA-only smoke 账号治理口径:企微测试账号有限,不再为了 OA 全域回归额外申请企业微信账号;admin、finance、HR、CEO 等公网 OA 回归账号只作为 OA-only smoke 使用,允许不绑定企微。企微/OA 一致性审计新增 `oaOnlySmokeExempted` 分类,命中条件限于私有 env、显式员工号 allowlist 或 `SMOKE*`/`smoke-*`/测试命名,避免再次把这些账号当作缺企微账号归档;账号恢复必须先 dry-run,真实 apply 需要 `OA_ONLY_SMOKE_REPAIR_APPLY=1`,且不打印密码、token、Secret 或完整标识。
- PC6、PA6、FL6、AP6、RI6、LP6、ME6 自动化、QG6、DG7/MRF7、MIA7 已完成相应收口。
- 2026-05-13 用户完成 iPhone 12、iPhone 14、Redmi K60 企业微信代表性真机主流程复测,`ME6-3` 已按主流程门禁关闭;iPhone 12 图文上下间距偏紧按非阻断观察项记录。
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检;2026-05-13 已将包含 `cf2bba0` 的 MIF7T 代码部署到 `https://oa.hyinda.com`。
+4 -3
查看文件
@@ -110,6 +110,7 @@
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | `CURRENT` | 正式模板外出/加班证据脚本合同:`SMOKE002` 人工提交、`SMOKE101` 先看详情再审批后,使用 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote` 从部署服务器只读拉取 outing/overtime;补偿拉取必须按 `creator=SMOKE002` 过滤,只写 OA 留存、离线读取和 WRULE1;不创建模板、不自动提交审批、不写企业微信 |
| `OAOPS1` | `issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv` | `CURRENT` | 当前 OA 主办域真实使用稳定化合同:企微 HR/考勤先冻结在企微主办 + OA 留存/审计完成态;公网 OA 进入 7 天小团队全员真实使用,允许真实业务数据,覆盖报销/PDF OCR、审批、采购/费用、合同台账、项目、付款批次、银行回单、AP 台账、代账交接、移动/企微入口和审计;新增 `scripts/qa/run-oa-real-use-daily.sh` 默认只读巡检,真实业务写入由人工完成;P2 冻结项继续冻结 |
| `ASSET1` | `issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv` | `CURRENT` | 当前统一资产台账 V1 合同:复用现有资产模块统一管理固定资产和电子资产索引;编号族为 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`,`EA-ACC` 暂缓;代码仓库由 Git 管源码,OA 只登记元数据;历史公司目录只允许脱敏索引 dry-run 或受控写入 |
| `OSMOKE1` | `issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv` | `CURRENT` | 当前 OA-only smoke 账号治理合同:企微账号有限时不再为 OA 全域自动化额外申请企微账号;admin/finance/hr/ceo 等回归账号作为 OA-only smoke,可不绑定企微;新增修复脚本、`oaOnlySmokeExempted` 审计豁免和公网角色 readiness 检查,豁免命中限于私有 env、显式员工号 allowlist 或 `SMOKE*`/`smoke-*`/测试命名;默认 dry-run,真实修复需 `OA_ONLY_SMOKE_REPAIR_APPLY=1` |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | `CURRENT` | 真实小范围试用准备合同:只固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;当前不启用真实员工,真实试用必须等请假/补卡/外出/加班四类测试号证据、WAPPR1 审批规则门禁和 WFULLSMOKE1 全域主路径可解释通过后再问 |
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
@@ -118,8 +119,8 @@
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | `rg` | none | 2026-05-22 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1/ASSET1 后优先读取 OA 主办域真实使用稳定化、统一资产台账、合同台账、阻断即修、每日巡检和 P2 冻结口径 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | `audit-docs` | none | 2026-05-22 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OAOPS1 小团队全员真实使用稳定化、ASSET1 统一资产台账、合同台账、每日巡检、阻断即修和冻结项事实 |
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1` | `rg` | none | 2026-05-22 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;OAOPS1/ASSET1/OSMOKE1 后优先读取 OA 主办域真实使用稳定化、统一资产台账、OA-only smoke 账号治理、每日巡检和 P2 冻结口径 |
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1` | `audit-docs` | none | 2026-05-22 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前 OAOPS1 小团队全员真实使用稳定化、ASSET1 统一资产台账、OSMOKE1 账号治理、每日巡检、阻断即修和冻结项事实 |
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-20 | `rg` | high | WARCH3/WCHECK1/WEXT1/WOPS1 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、只读历史查询、自动巡检、打卡摘要、外出/加班 dry-run、WEXT1-FORMAL、WREAL1 前置和深度镜像边界;ERP 未来不接入 OA |
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2FLIVE,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCBK1 真实回调事件证据、WEGOV1 混合主办边界和 OA 自建留存策略 |
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9,WFDG` | Playwright/视觉审查/DG8/DG9/WFDG | none | 2026-05-18 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型;WFDG 后区分 OA 主办、企微镜像和 OA 应急接管 |
@@ -129,7 +130,7 @@
| `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8,WPROC1,WPROC2,WAPC2FLIVE,WCBK1,WEGOV1` | 回归脚本/Playwright | none | 2026-05-19 | `rg` | high | 测试计划、DONE 门禁、企微三层门禁、自动化优先、非自审、无完整 sandbox 假设、WREAD1 真机可读性和 WARCH1 留存验收门禁 |
| `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 |
| `AGENTS` | `AGENTS.md` | 执行硬约束 | 项目硬规则 | `CURRENT` | lead | `GOV8` | 人工核对 | none | 2026-05-13 | `rg` | high | 不可违反约束 |
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1` | quick_validate | none | 2026-05-22 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图和 ASSET1 资产边界;不得绕过 WROAD2C/WROAD2B/WAPPR1/WRULE1/WAUTO1/WEXT1LD/WFULLSMOKE1/WTCLOSE2/WEXT1-FORMAL/WREAL1 或后续独立合同直接做完整考勤审批镜像;统一资产台账不保存密码、Secret、源码包、完整配置或涉密原文 |
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1,WOPS1,WROAD2C,WROAD2B,WAPPR1,WRULE1,WAUTO1,WEXT1LD,WFULLSMOKE1,WROUTE2,WTCLOSE1,WTCLOSE2,WREAL1P,OAOPS1,ASSET1,OSMOKE1` | quick_validate | none | 2026-05-22 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、ASSET1 资产边界和 OSMOKE1 OA-only smoke 账号治理;不得绕过 WROAD2C/WROAD2B/WAPPR1/WRULE1/WAUTO1/WEXT1LD/WFULLSMOKE1/WTCLOSE2/WEXT1-FORMAL/WREAL1 或后续独立合同直接做完整考勤审批镜像;统一资产台账不保存密码、Secret、源码包、完整配置或涉密原文 |
| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WAPPR1,WAUTO1,WEXT1LD,WFULLSMOKE1,WTCLOSE1,WRULE1,WTCLOSE2` | workflow 回归 | none | 2026-05-22 | `rg` | high | OA 主办审批、企微主办 HR/考勤审批、OA 自建留存、真实回调事件证据、WACP3R 补偿拉取 findings、WREAD1 请假/补卡真机可读性结论、WARCH1 最小历史留存、WAPPR1 审批规则规范化和 API 不支持读取规则边界、WTCLOSE1 小团队一层审批 + 管理员兜底目标 A、WRULE1 理论审批人与实际审批链校验、WTCLOSE2 测试号最终收口、WAUTO1/WEXT1LD/WFULLSMOKE1 前置和 OA 应急接管边界 |
| `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 |
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
+2 -1
查看文件
@@ -20,7 +20,7 @@
## 最近事实源链
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL/WEXT1F 正式模板外出/加班测试 -> OAOPS1 小团队全员真实使用稳定化 -> ASSET1 电子资产与固定资产统一台账(当前实现)-> 后续再按需问答 WREAL1/最小镜像。
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WOPS1 巡检告警降噪 -> WROAD2-CLOSEOUT 三天验收 -> WROAD2-BIZ1 三天业务冒烟 -> WAPPR1 审批规则规范化 -> WRULE1 审批链校验器 POC -> WAUTO1 自动证据汇总 -> WEXT1LD live 前 dry-run -> WFULLSMOKE1 三天全域最终目标测试 -> WROUTE2 测试号收口到最小镜像路线锁定 -> WTCLOSE1 小团队自动审批规则与测试号全域收口 dry-run -> WTCLOSE2 测试号最终收口与证据复核 -> WTCLOSE2F 远端可信 IP 执行口径修正 -> WREMOTE1 远端可信 IP 收口 -> WFULLSMOKE2 全域 smoke -> WEXT1-FORMAL/WEXT1F 正式模板外出/加班测试 -> OAOPS1 小团队全员真实使用稳定化 -> ASSET1 电子资产与固定资产统一台账 -> OSMOKE1 OA-only smoke 账号治理与公网自动化恢复(当前实现)-> 后续再按需问答 WREAL1/最小镜像。
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
@@ -171,6 +171,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
| `WEXT1F` | `issues/2026-05-22_14-23-19-wecom-formal-outing-overtime-evidence-v1.csv` | 当前正式模板外出/加班证据脚本:人工提交后从部署服务器只读收证据 | 新增 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`;显式拉取 `outing/overtime` 且按 `creator=SMOKE002` 过滤,写 OA 留存仅限 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db`;随后做离线归档读取和 WRULE1 | 不创建模板、不自动提交审批、不写企业微信、不启用真实员工;未有人工作业或缺确认标记时只可得到人工缺口结论 |
| `OAOPS1` | `issues/2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv` | 当前 OA 主办域真实使用稳定化:7 天小团队全员边用边修 | 新增 `scripts/qa/run-oa-real-use-daily.sh`,每日只读核验公网 ready、live smoke、feature flag、报销/PDF OCR、审批、采购/费用、合同台账、项目、付款批次、AP、移动/企微入口和隐私边界 | 允许真实业务数据进入公网 OA 并按审计保留;真实写入由人工完成;涉密合同正文、敏感报价、成本细节、银行流水原件、未脱敏票面不进仓库/聊天/报告;图片 OCR、复杂 BPM、预算、ERP、银行流水导入、真实支付直连、企微 HR 深镜像、应急接管、恢复回写、RuleOps 继续冻结 |
| `ASSET1` | `issues/2026-05-22_21-18-33-oa-asset-governance-v1.csv` | 当前统一资产台账 V1:电子资产 + 固定资产共用现有资产模块 | 扩展 `AssetCard` 为 `FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP`,新增资产大类、敏感级别、负责人/保管人、归档位置、外部引用、hash、历史验证编号、最近验证时间和脱敏索引 dry-run;前端资产页支持类型/敏感级别/编号搜索 | 代码仓库仍由 Git 管理;`EA-ACC` 暂缓;不保存密码、token、Secret、源码包、完整配置、涉密正文、真实完整路径、敏感报价、银行流水原件或未脱敏票面 |
| `OSMOKE1` | `issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv` | 当前 OA-only smoke 账号治理:恢复公网 OA 自动化高权限测试账号,不再额外申请企微账号 | 新增 OA-only smoke 修复脚本、企微/OA 一致性审计豁免 `oaOnlySmokeExempted`、公网角色 readiness 检查,并接入 OAOPS1 每日巡检 | 只处理私有 env、显式员工号 allowlist 或 `SMOKE*`/`smoke-*`/测试命名命中的 smoke 标识;默认 dry-run,apply 必须 `OA_ONLY_SMOKE_REPAIR_APPLY=1`;不写企业微信、不重置密码、不影响真实员工、不执行 EA-DOC live 导入 |
| `WREAL1-PREP` | `issues/2026-05-20_14-35-42-wecom-real-trial-prep-v1.csv` | 当前真实试用准备:不启用真实员工 | 固化候选员工、非自审审批人、回滚联系人、允许流程、暂停条件和验收标准;真实试用必须等请假、补卡、外出、加班四类测试号证据、WAPPR1 和 WFULLSMOKE1 通过后再问 | 当前不创建外出/加班 live 样件;真实员工名单不写入 Issue 或文档,后续通过问答确认 |
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
+1
查看文件
@@ -9,6 +9,7 @@
**WTCLOSE2/WTCLOSE2F 测试号最终收口**:企业微信 API 已限制只能由部署服务器可信 IP 访问,因此命令必须使用 `bash scripts/qa/run-wecom-test-account-final-closeout.sh --remote`。本地直接执行脚本会拒绝跑企微链路,只能用于 `bash -n`、合同和文档静态门禁;不得把本地缺 env/DB 的结果当作业务阻断。远端脚本复跑 WAUTO1、WFULLSMOKE1、外出/加班 dry-run 和 WRULE1,并汇总 WROAD2/WARCH3 既有报告;WFULLSMOKE1 在该脚本中关闭公网业务写流程,不新增 OA 测试业务数据;如子 runner 触发既有归档写库,只能走 WARCH3/WCHECK1 已批准的 OA 自建留存路径;脚本不写企业微信、不创建审批样件、不启用真实员工。
**WEXT1F 正式模板外出/加班证据脚本**:管理员或 QA 只在人工测试完成后执行 `WEXT1F_MANUAL_CONFIRMATION=SMOKE002_SUBMITTED_SMOKE101_REVIEWED bash scripts/qa/run-wecom-formal-outing-overtime-evidence.sh --remote`。人工步骤固定为:`SMOKE002` 在企业微信正式外出模板提交低风险测试单,确认审批人自动带出测试主管且不能改人;再在正式加班模板提交低风险测试单并做同样确认;`SMOKE101` 必须先打开详情页再同意或驳回,不能直接点快捷同意。缺少该人工确认标记时脚本只输出 `PAUSED_MANUAL_CONFIRMATION`,不跑企微 API。脚本从部署服务器可信 IP 读取企微审批 API,不创建模板、不自动提交审批、不写企业微信;补偿拉取必须同时按 `template_id` 和 `creator=SMOKE002` 过滤,避免读取同一正式模板下其他员工单据;仅在既有 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db` 双开关下把拉到的外出/加班详情写入 OA 自建留存,然后做离线归档读取和 WRULE1 审批链校验。
**OAOPS1 小团队真实使用稳定化**:企微打卡、补卡、外出、请假先冻结在当前完成态,日常继续由企业微信主办,OA 做留存、审计和后续兜底。当前主线回到 OA 主办域,按 7 天小团队全员真实使用稳定化执行;允许公网 OA 录入真实报销、采购/费用、合同台账、项目、付款批次和银行回单等业务数据,数据按审计保留不自动清理。新增 `scripts/qa/run-oa-real-use-daily.sh` 作为每日只读巡检;真实业务写入只由人工在公网 OA 内完成。图片 OCR、复杂 BPM、预算、ERP、银行流水导入、真实支付直连、企微 HR 深镜像、应急接管、恢复回写和 RuleOps 继续冻结。
**OSMOKE1 OA-only smoke 账号治理**:企业微信账号数量有限时,不再为了 OA 报销、资产、财务、AP、合同或权限回归额外申请企业微信账号。企微测试账号只服务 HR/考勤链路;公网 OA 自动化使用 OA-only smoke 账号,允许不绑定企微,但必须在 OA 内保持 active、角色正确且可登录。企微/OA 一致性审计会把私有 env 中列出、显式员工号 allowlist 命中,或符合 `SMOKE*`/`smoke-*`/`oa-smoke-*`/测试命名的 OA-only smoke 账号归为 `oaOnlySmokeExempted`,不再当作“缺企微账号”归档;真实员工不得用该命名绕过一致性治理。恢复账号必须先 dry-run,真实修复必须 `--apply` 且设置 `OA_ONLY_SMOKE_REPAIR_APPLY=1`,不得重置密码、不得打印凭据、不得影响真实员工。
## 1. 适用范围
- 适用于 `small_team_internal` 工作区模式。
@@ -0,0 +1,10 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
OSMOKE1-0,建立 OA-only smoke 账号治理合同,"承接 ASSET1D/OAOPS1 阻断,固定企微测试账号与 OA 全域回归账号解耦口径。","Issue CSV 通过校验;范围固定为恢复 OA-only smoke 自动化能力,不新增企微账号、不写企业微信、不执行资产 EA-DOC live 导入。","受影响流程:公网 OA 自动化 -> 高权限 smoke 登录 -> 资产/财务/权限回归;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv。","python3|manual",DONE,DONE,DONE,"issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv","[]","本合同不打印、不提交、不复述密码、token、Secret、完整本地路径或真实员工信息。"
OSMOKE1-1,实现 OA-only smoke 修复脚本,"新增 QA/Ops 脚本,按本地私有 live smoke env 中的标识定位 OA-only smoke 账号,默认 dry-run,apply 必须双开关。","脚本默认只报告计划;真实修复必须 --apply 且 OA_ONLY_SMOKE_REPAIR_APPLY=1;只恢复 active 状态、清登录锁、补齐角色,不重置密码、不影响真实员工。","受影响流程:管理员准备公网自动化账号 -> dry-run 查看计划 -> 人工确认 -> apply 修复;验证步骤:cd server && node --check src/scripts/repair-oa-only-smoke-accounts.js。","node",DONE,DONE,DONE,"server/src/scripts/repair-oa-only-smoke-accounts.js|server/package.json","[""OSMOKE1-0""]","回滚路径:如误恢复,仅对本脚本报告中的 smoke 员工按员工管理流程归档;不得批量影响真实员工。"
OSMOKE1-2,调整企微/OA 一致性审计与 resolver 豁免,"将 OA-only smoke 账号从 oaActiveMissingInWecom finding 中移出,历史报告进入 resolver 时也支持员工号豁免。","审计报告新增 oaOnlySmokeExempted 分类且不计入阻断 finding;resolver 报告不对豁免员工计划 archive_oa_test_employee。","受影响流程:企微/OA 用户一致性审计 -> 差异修复计划 -> 防止 OA-only smoke 被再次归档;验证步骤:cd server && node --check src/scripts/audit-wecom-oa-user-consistency.js && node --check src/scripts/resolve-wecom-oa-user-consistency.js。","node",DONE,DONE,DONE,"server/src/scripts/audit-wecom-oa-user-consistency.js|server/src/scripts/resolve-wecom-oa-user-consistency.js","[""OSMOKE1-1""]","不改变真实员工一致性规则;OA-only smoke 仅来自私有 env 标识、显式员工号 allowlist 或 SMOKE*/smoke-*/测试命名。"
OSMOKE1-3,新增 OA-only smoke readiness 检查并接入 OAOPS1,"新增公网只读 readiness 检查 admin/employee/manager/finance/finance_mgr/hr/ceo 角色登录与入口可用性,并接入每日巡检。","报告只显示角色、脱敏状态和入口结果;不输出账号密码;run-oa-real-use-daily 先执行账号 readiness,再继续资产/财务/移动入口巡检。","受影响流程:OAOPS1 每日巡检 -> 角色账号可用性 -> 资产/财务自动化前置;验证步骤:node --check scripts/qa/check-oa-only-smoke-readiness.mjs && bash -n scripts/qa/run-oa-real-use-daily.sh。","node|bash",DONE,DONE,DONE,"scripts/qa/check-oa-only-smoke-readiness.mjs|scripts/qa/run-oa-real-use-daily.sh","[""OSMOKE1-1""]","readiness 可作为 finding,不代表自动修复已执行。"
OSMOKE1-4,回写事实源和操作口径,"更新现有管理员手册、试运行报告、文档索引、事实源矩阵和项目 skill,固化 OA-only smoke 与企微测试账号分离。","后来者能从事实源看到:不再为 OA 测试申请更多企微账号;OA-only smoke 可不绑定企微;修复账号必须 dry-run + 人工确认 + 双开关 apply。","受影响流程:交付人员读取文档 -> 判断当前阻断 -> 执行正确账号治理;验证步骤:rg -n ""OSMOKE1|OA-only smoke|oaOnlySmokeExempted|OA_ONLY_SMOKE_REPAIR_APPLY"" docs .agents/skills/hyb-oa-delivery/SKILL.md。","rg|node|python3",DONE,DONE,DONE,"docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""OSMOKE1-1"",""OSMOKE1-2"",""OSMOKE1-3""]","优先更新现有事实源,不新建孤立文档。"
OSMOKE1-5,本地静态与最小门禁,"执行合同校验、process gate、Prisma validate、脚本语法、文档审计、skill 校验、diff 和最小回归。","本地门禁通过;若存在既有文档 warning,记录但不伪造成业务失败。","受影响流程:开发候选 -> 可部署候选;验证步骤:validate_issues_csv;node scripts/qa/check-process-contract.mjs --baseline --json;cd server && npm run prisma:validate;node --check affected scripts;bash -n scripts/qa/run-oa-real-use-daily.sh;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。","python3|node|npm|bash|git",DONE,DONE,DONE,"server/src/scripts/*|scripts/qa/*|docs|issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv","[""OSMOKE1-4""]","本地门禁通过:validate/process gate/prisma/node check/bash -n/audit-docs/quick_validate/git diff/check-minimal 均通过;focused vitest 3 files 21 tests passed;audit-docs 保留既有 unregistered docs files 24 warning。回滚/清理路径为删除本轮未提交代码与 QA-only 脚本变更,生成报告只保留脱敏摘要且不影响公网数据。"
OSMOKE1-6,部署公网并生成远端 dry-run,"部署 OSMOKE1 到公网 OA,远端只执行 OA-only smoke 修复 dry-run 和 readiness 检查,不 apply。","部署成功;/ready 正常;dry-run 报告显示会恢复哪些 OA-only smoke 角色或显示无待修复;未执行真实账号修复。","受影响流程:本地代码 -> 公网部署 -> 远端 dry-run -> 人工确认 gate;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run repair:oa-only-smoke -- --format=json;公网 readiness 只读检查。","bash|ssh|node",DONE,DONE,DONE,"deploy/trialrun/deploy.sh|server/src/scripts/repair-oa-only-smoke-accounts.js|scripts/qa/check-oa-only-smoke-readiness.mjs","[""OSMOKE1-5""]","公网部署完成;/ready ok,public bundle findings=0;远端 dry-run 报告 /app/artifacts/qa-flow/oa-only-smoke-repair-20260523-osmoke1-dryrun/summary.json 已复制到宿主机 /opt/hyb-oa-trialrun/artifacts/qa-flow/oa-only-smoke-repair-20260523-osmoke1-dryrun/summary.json,结论 PASS_WITH_REPAIR_PLAN:7 targets,plannedRepairs=5,readyTargets=2,blockedTargets=0,未 apply;公网 readiness 显示 employee/manager PASS,admin/ceo/finance/finance_mgr/hr 因 archived 返回 AUTH_003。部署回滚按 trialrun runbook 回退到上一提交;dry-run 无数据库写入;apply 前必须回到用户问答:A 恢复现有 OA-only smoke;B 新建新账号;C 暂停自动化。"
OSMOKE1-7,人工确认后远端 apply 与复测,"仅在用户确认 A 后,使用 --apply + OA_ONLY_SMOKE_REPAIR_APPLY=1 远端恢复现有 OA-only smoke 账号,然后复跑 OAOPS1 和 ASSET1D UI 自动化。","修复报告状态为 APPLIED;高权限 smoke 账号登录恢复;OAOPS1 账号 readiness 通过或仅剩可解释 finding;ASSET1D-4 可继续 UI 试录。","受影响流程:账号恢复 -> 角色入口 -> 资产台账/财务/AP/权限公网复测;验证步骤:远端 apply 后执行 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh;启动只读 QA subagent/Playwright 复测资产 UI。","ssh|node|bash|playwright|manual",TODO,TODO,TODO,"external|artifacts/qa-flow","[""OSMOKE1-6""]","本轮不默认执行;等待用户确认。"
OSMOKE1-8,reviewer/QA 收口与提交,"账号状态、权限和生命周期清理属于高风险域,实施后启动只读 reviewer subagent;汇总验证结果并中文提交。","reviewer 不改代码,只输出意见;QA 结果回填 CSV;提交信息引用 OSMOKE1;若未获得 apply 确认,OSMOKE1-7 保持 TODO/PAUSED。","受影响流程:高风险修复 -> 只读评审 -> 回归证据 -> 提交;验证步骤:reviewer subagent 检查 diff/报告;git status;git diff --check;git commit。","manual|git|subagent",DONE,DONE,DONE,"issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv","[""OSMOKE1-5""]","只读 reviewer 复核无 blocker,可在 OSMOKE1-7 未执行前提交当前 dry-run 和代码变更;已修正文案为 businessReadOnly;未执行远端 apply,未执行 EA-DOC live 导入,OSMOKE1-7 继续 TODO。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 OSMOKE1-0 建立 OA-only smoke 账号治理合同 承接 ASSET1D/OAOPS1 阻断,固定企微测试账号与 OA 全域回归账号解耦口径。 Issue CSV 通过校验;范围固定为恢复 OA-only smoke 自动化能力,不新增企微账号、不写企业微信、不执行资产 EA-DOC live 导入。 受影响流程:公网 OA 自动化 -> 高权限 smoke 登录 -> 资产/财务/权限回归;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv。 python3|manual DONE DONE DONE issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv [] 本合同不打印、不提交、不复述密码、token、Secret、完整本地路径或真实员工信息。
3 OSMOKE1-1 实现 OA-only smoke 修复脚本 新增 QA/Ops 脚本,按本地私有 live smoke env 中的标识定位 OA-only smoke 账号,默认 dry-run,apply 必须双开关。 脚本默认只报告计划;真实修复必须 --apply 且 OA_ONLY_SMOKE_REPAIR_APPLY=1;只恢复 active 状态、清登录锁、补齐角色,不重置密码、不影响真实员工。 受影响流程:管理员准备公网自动化账号 -> dry-run 查看计划 -> 人工确认 -> apply 修复;验证步骤:cd server && node --check src/scripts/repair-oa-only-smoke-accounts.js。 node DONE DONE DONE server/src/scripts/repair-oa-only-smoke-accounts.js|server/package.json ["OSMOKE1-0"] 回滚路径:如误恢复,仅对本脚本报告中的 smoke 员工按员工管理流程归档;不得批量影响真实员工。
4 OSMOKE1-2 调整企微/OA 一致性审计与 resolver 豁免 将 OA-only smoke 账号从 oaActiveMissingInWecom finding 中移出,历史报告进入 resolver 时也支持员工号豁免。 审计报告新增 oaOnlySmokeExempted 分类且不计入阻断 finding;resolver 报告不对豁免员工计划 archive_oa_test_employee。 受影响流程:企微/OA 用户一致性审计 -> 差异修复计划 -> 防止 OA-only smoke 被再次归档;验证步骤:cd server && node --check src/scripts/audit-wecom-oa-user-consistency.js && node --check src/scripts/resolve-wecom-oa-user-consistency.js。 node DONE DONE DONE server/src/scripts/audit-wecom-oa-user-consistency.js|server/src/scripts/resolve-wecom-oa-user-consistency.js ["OSMOKE1-1"] 不改变真实员工一致性规则;OA-only smoke 仅来自私有 env 标识、显式员工号 allowlist 或 SMOKE*/smoke-*/测试命名。
5 OSMOKE1-3 新增 OA-only smoke readiness 检查并接入 OAOPS1 新增公网只读 readiness 检查 admin/employee/manager/finance/finance_mgr/hr/ceo 角色登录与入口可用性,并接入每日巡检。 报告只显示角色、脱敏状态和入口结果;不输出账号密码;run-oa-real-use-daily 先执行账号 readiness,再继续资产/财务/移动入口巡检。 受影响流程:OAOPS1 每日巡检 -> 角色账号可用性 -> 资产/财务自动化前置;验证步骤:node --check scripts/qa/check-oa-only-smoke-readiness.mjs && bash -n scripts/qa/run-oa-real-use-daily.sh。 node|bash DONE DONE DONE scripts/qa/check-oa-only-smoke-readiness.mjs|scripts/qa/run-oa-real-use-daily.sh ["OSMOKE1-1"] readiness 可作为 finding,不代表自动修复已执行。
6 OSMOKE1-4 回写事实源和操作口径 更新现有管理员手册、试运行报告、文档索引、事实源矩阵和项目 skill,固化 OA-only smoke 与企微测试账号分离。 后来者能从事实源看到:不再为 OA 测试申请更多企微账号;OA-only smoke 可不绑定企微;修复账号必须 dry-run + 人工确认 + 双开关 apply。 受影响流程:交付人员读取文档 -> 判断当前阻断 -> 执行正确账号治理;验证步骤:rg -n "OSMOKE1|OA-only smoke|oaOnlySmokeExempted|OA_ONLY_SMOKE_REPAIR_APPLY" docs .agents/skills/hyb-oa-delivery/SKILL.md。 rg|node|python3 DONE DONE DONE docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md ["OSMOKE1-1","OSMOKE1-2","OSMOKE1-3"] 优先更新现有事实源,不新建孤立文档。
7 OSMOKE1-5 本地静态与最小门禁 执行合同校验、process gate、Prisma validate、脚本语法、文档审计、skill 校验、diff 和最小回归。 本地门禁通过;若存在既有文档 warning,记录但不伪造成业务失败。 受影响流程:开发候选 -> 可部署候选;验证步骤:validate_issues_csv;node scripts/qa/check-process-contract.mjs --baseline --json;cd server && npm run prisma:validate;node --check affected scripts;bash -n scripts/qa/run-oa-real-use-daily.sh;node scripts/qa/audit-docs.mjs --check-registry;quick_validate.py .agents/skills/hyb-oa-delivery;git diff --check;bash scripts/check-minimal.sh。 python3|node|npm|bash|git DONE DONE DONE server/src/scripts/*|scripts/qa/*|docs|issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv ["OSMOKE1-4"] 本地门禁通过:validate/process gate/prisma/node check/bash -n/audit-docs/quick_validate/git diff/check-minimal 均通过;focused vitest 3 files 21 tests passed;audit-docs 保留既有 unregistered docs files 24 warning。回滚/清理路径为删除本轮未提交代码与 QA-only 脚本变更,生成报告只保留脱敏摘要且不影响公网数据。
8 OSMOKE1-6 部署公网并生成远端 dry-run 部署 OSMOKE1 到公网 OA,远端只执行 OA-only smoke 修复 dry-run 和 readiness 检查,不 apply。 部署成功;/ready 正常;dry-run 报告显示会恢复哪些 OA-only smoke 角色或显示无待修复;未执行真实账号修复。 受影响流程:本地代码 -> 公网部署 -> 远端 dry-run -> 人工确认 gate;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端执行 npm run repair:oa-only-smoke -- --format=json;公网 readiness 只读检查。 bash|ssh|node DONE DONE DONE deploy/trialrun/deploy.sh|server/src/scripts/repair-oa-only-smoke-accounts.js|scripts/qa/check-oa-only-smoke-readiness.mjs ["OSMOKE1-5"] 公网部署完成;/ready ok,public bundle findings=0;远端 dry-run 报告 /app/artifacts/qa-flow/oa-only-smoke-repair-20260523-osmoke1-dryrun/summary.json 已复制到宿主机 /opt/hyb-oa-trialrun/artifacts/qa-flow/oa-only-smoke-repair-20260523-osmoke1-dryrun/summary.json,结论 PASS_WITH_REPAIR_PLAN:7 targets,plannedRepairs=5,readyTargets=2,blockedTargets=0,未 apply;公网 readiness 显示 employee/manager PASS,admin/ceo/finance/finance_mgr/hr 因 archived 返回 AUTH_003。部署回滚按 trialrun runbook 回退到上一提交;dry-run 无数据库写入;apply 前必须回到用户问答:A 恢复现有 OA-only smoke;B 新建新账号;C 暂停自动化。
9 OSMOKE1-7 人工确认后远端 apply 与复测 仅在用户确认 A 后,使用 --apply + OA_ONLY_SMOKE_REPAIR_APPLY=1 远端恢复现有 OA-only smoke 账号,然后复跑 OAOPS1 和 ASSET1D UI 自动化。 修复报告状态为 APPLIED;高权限 smoke 账号登录恢复;OAOPS1 账号 readiness 通过或仅剩可解释 finding;ASSET1D-4 可继续 UI 试录。 受影响流程:账号恢复 -> 角色入口 -> 资产台账/财务/AP/权限公网复测;验证步骤:远端 apply 后执行 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-oa-real-use-daily.sh;启动只读 QA subagent/Playwright 复测资产 UI。 ssh|node|bash|playwright|manual TODO TODO TODO external|artifacts/qa-flow ["OSMOKE1-6"] 本轮不默认执行;等待用户确认。
10 OSMOKE1-8 reviewer/QA 收口与提交 账号状态、权限和生命周期清理属于高风险域,实施后启动只读 reviewer subagent;汇总验证结果并中文提交。 reviewer 不改代码,只输出意见;QA 结果回填 CSV;提交信息引用 OSMOKE1;若未获得 apply 确认,OSMOKE1-7 保持 TODO/PAUSED。 受影响流程:高风险修复 -> 只读评审 -> 回归证据 -> 提交;验证步骤:reviewer subagent 检查 diff/报告;git status;git diff --check;git commit。 manual|git|subagent DONE DONE DONE issues/2026-05-23_13-59-52-oa-only-smoke-account-governance-v1.csv ["OSMOKE1-5"] 只读 reviewer 复核无 blocker,可在 OSMOKE1-7 未执行前提交当前 dry-run 和代码变更;已修正文案为 businessReadOnly;未执行远端 apply,未执行 EA-DOC live 导入,OSMOKE1-7 继续 TODO。
@@ -0,0 +1,294 @@
#!/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_ONLY_SMOKE_READINESS_REPORT_DIR
|| path.resolve(`artifacts/qa-flow/oa-only-smoke-readiness-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}`);
const ROLE_TARGETS = [
{
key: "admin",
identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_ADMIN_PASSWORD",
expectedRoles: ["admin", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "assets", path: "/api/assets?pageSize=1" },
{ id: "users", path: "/api/users?page=1&pageSize=1" },
],
},
{
key: "employee",
identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_EMPLOYEE_PASSWORD",
expectedRoles: ["employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "my-assets", path: "/api/assets?pageSize=1&mine=1" },
],
},
{
key: "manager",
identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_MANAGER_PASSWORD",
expectedRoles: ["manager", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "workflow", path: "/api/workflow/tasks?page=1&pageSize=1" },
],
},
{
key: "ceo",
identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_CEO_PASSWORD",
expectedRoles: ["ceo", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "dashboard", path: "/api/dashboard/expense-overview" },
],
},
{
key: "finance",
identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_PASSWORD",
expectedRoles: ["finance", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "payment-batches", path: "/api/payment-batches?page=1&pageSize=1" },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?page=1&pageSize=1" },
],
},
{
key: "finance_mgr",
identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_FINANCE_MGR_PASSWORD",
expectedRoles: ["finance", "finance_mgr", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "payment-batches", path: "/api/payment-batches?page=1&pageSize=1" },
{ id: "ap-ledger", path: "/api/ap-operations/reimbursement-ledger?page=1&pageSize=1" },
],
},
{
key: "hr",
identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER",
passwordEnv: "LIVE_SMOKE_HR_PASSWORD",
expectedRoles: ["hr", "employee"],
probes: [
{ id: "me", path: "/api/me" },
{ id: "portal", path: "/api/portal/home" },
{ id: "wecom-archive", path: "/api/wecom/approval-archive?page=1&pageSize=1&scope=all" },
],
},
];
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(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 160);
}
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)) };
}
}
function summarizeData(json) {
const data = json?.data;
if (Array.isArray(data?.items)) return { itemCount: data.items.length, total: data.total ?? null };
if (Array.isArray(data)) return { itemCount: data.length };
if (typeof data === "object" && data) {
const summary = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === "number" || typeof value === "boolean") summary[key] = value;
if (/^(status|mode|conclusion)$/i.test(key) && typeof value === "string" && /^[a-z0-9_/-]{1,60}$/i.test(value)) {
summary[key] = value;
}
if (key === "roleCodes" && Array.isArray(value) && value.every((item) => typeof item === "string")) {
summary.roleCodes = value.slice().sort();
}
if (key === "permissionCodes" && Array.isArray(value)) {
summary.permissionCount = value.length;
}
}
return summary;
}
return {};
}
async function login(target) {
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),
};
}
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": `oa-only-smoke-${target.key}`,
},
body: JSON.stringify({ identifier, password }),
});
const accessToken = result.json?.data?.accessToken;
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
return {
status: "FAIL",
reason: `login_http_${result.status}`,
code: result.json?.error?.code ?? null,
maskedIdentifier: maskIdentifier(identifier),
};
}
return {
status: "PASS",
accessToken,
maskedIdentifier: maskIdentifier(identifier),
};
}
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",
probes: [],
};
if (loginState.status !== "PASS") {
return result;
}
for (const probe of target.probes) {
const response = await fetchJson(`${baseUrl}${probe.path}`, {
headers: {
accept: "application/json",
authorization: `Bearer ${loginState.accessToken}`,
},
});
const summary = response.ok ? summarizeData(response.json) : {
code: response.json?.error?.code ?? null,
message: sanitizeError(response.json?.error?.message ?? response.error ?? response.text),
};
result.probes.push({
id: probe.id,
status: response.ok ? "PASS" : "FAIL",
httpStatus: response.status,
summary,
});
if (probe.id === "me" && response.ok) {
const actualRoles = response.json?.data?.roleCodes || response.json?.data?.roles || [];
const missingRoles = target.expectedRoles.filter((role) => !actualRoles.includes(role));
result.roleMatch = missingRoles.length ? "FAIL" : "PASS";
result.actualRoles = actualRoles.filter((role) => typeof role === "string").sort();
result.missingRoles = missingRoles;
}
}
return result;
}
async function main() {
mkdirSync(reportDir, { recursive: true });
const results = [];
for (const target of ROLE_TARGETS) {
results.push(await runTarget(target));
}
const blocking = 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 probe of target.probes) {
if (probe.status === "FAIL") issues.push({ key: target.key, source: probe.id, status: "FAIL", httpStatus: probe.httpStatus, code: probe.summary?.code ?? null });
}
return issues;
});
const skipped = results.filter((target) => target.login.status === "SKIP");
let status = "PASS_OA_ONLY_SMOKE_READY";
if (blocking.length) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (skipped.length) {
status = "PASS_WITH_FINDINGS";
}
const summary = {
issueId: "OSMOKE1",
mode: "oa-only-smoke-readiness",
status,
conclusion: status,
baseUrl,
checkedAt: new Date().toISOString(),
businessReadOnly: true,
readOnlyNote: "performs login/readiness probes only; no business data writes and no WeCom writes",
privacy: "role/masked identifier/status/counts only; no password/token/secret/full email/full phone",
targets: results.map(({ accessToken, ...item }) => item),
findings: [
...blocking,
...skipped.map((target) => ({ key: target.key, source: "credentials", status: "SKIP", reason: target.login.reason })),
],
boundaries: {
noBusinessWrites: true,
noWeComWrites: true,
noPasswordOutput: true,
noRealEmployeeIdentifiers: true,
},
};
const summaryPath = path.join(reportDir, "summary.json");
writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify(summary, null, 2));
console.log(`[oa-only-smoke-readiness] report=${summaryPath}`);
if (blocking.length) process.exit(1);
}
main().catch((error) => {
console.error(`[oa-only-smoke-readiness][FAIL] ${sanitizeError(error instanceof Error ? error.message : String(error))}`);
process.exit(1);
});
+2
查看文件
@@ -122,6 +122,7 @@ run_ready_and_runtime() {
run_step "public-ready" curl -fsS "$PUBLIC_BASE_URL/ready"
run_finding_step "live-smoke-env" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-smoke-env.mjs" --json
run_finding_step "live-readiness" env QA_ISSUE_ID=OAOPS1 LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" LIVE_SMOKE_READINESS_DIR="$REPORT_DIR/live-smoke-readiness" bash "$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh"
run_finding_step "oa-only-smoke-readiness" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" OA_ONLY_SMOKE_READINESS_REPORT_DIR="$REPORT_DIR/oa-only-smoke-readiness" node "$ROOT_DIR/scripts/qa/check-oa-only-smoke-readiness.mjs"
run_finding_step "live-feature-flags" env LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" node "$ROOT_DIR/scripts/qa/check-live-feature-flags.mjs" \
--expect payment_batch_v1=force_on \
--expect erp_sync=force_off \
@@ -394,6 +395,7 @@ const stepNames = [
"public-ready",
"live-smoke-env",
"live-readiness",
"oa-only-smoke-readiness",
"live-feature-flags",
"authenticated-readonly-probe",
"mobile-entry-matrix",
+1
查看文件
@@ -29,6 +29,7 @@
"audit:smoke-data": "node src/scripts/audit-smoke-data.js",
"audit:org-master-data": "node src/scripts/audit-org-master-data.js",
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
"repair:oa-only-smoke": "node src/scripts/repair-oa-only-smoke-accounts.js",
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.js",
"check:wecom-approval-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js",
"check:wecom-approval-compensation-poc": "node src/scripts/check-wecom-approval-compensation-poc.js",
@@ -15,6 +15,15 @@ const {
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_LIMIT = 200;
const MAX_REPORT_ITEMS = 200;
const OA_ONLY_SMOKE_IDENTIFIER_ENVS = [
"LIVE_SMOKE_ADMIN_IDENTIFIER",
"LIVE_SMOKE_EMPLOYEE_IDENTIFIER",
"LIVE_SMOKE_MANAGER_IDENTIFIER",
"LIVE_SMOKE_CEO_IDENTIFIER",
"LIVE_SMOKE_FINANCE_IDENTIFIER",
"LIVE_SMOKE_FINANCE_MGR_IDENTIFIER",
"LIVE_SMOKE_HR_IDENTIFIER",
];
function printHelp() {
process.stdout.write(`Usage: node src/scripts/audit-wecom-oa-user-consistency.js [options]
@@ -90,6 +99,43 @@ function normalizeKey(value) {
return raw ? raw.toLowerCase() : "";
}
function splitList(value) {
return String(value ?? "")
.split(/[\s,]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function buildOaOnlySmokeIdentifierSet(env = process.env) {
const identifiers = [
...splitList(env.OA_ONLY_SMOKE_IDENTIFIERS),
...OA_ONLY_SMOKE_IDENTIFIER_ENVS.map((key) => env[key]).filter(Boolean),
];
return new Set(identifiers.map(normalizeKey).filter(Boolean));
}
function isDefaultOaOnlySmokeCandidate(employee) {
const employeeNo = String(employee?.employeeNo ?? "").trim();
const emailLocal = String(employee?.email ?? "").trim().toLowerCase().split("@")[0] || "";
const name = String(employee?.name ?? "");
return (
/^SMOKE[A-Z0-9_-]*/i.test(employeeNo)
|| /^smoke[-_.]/.test(emailLocal)
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|| /测试|试运行/.test(name)
);
}
function isOaOnlySmokeEmployee(employee, identifierSet) {
if (!employee) return false;
if (isDefaultOaOnlySmokeCandidate(employee)) return true;
if (!identifierSet?.size) return false;
return [employee.employeeNo, employee.email, employee.phone]
.map(normalizeKey)
.filter(Boolean)
.some((key) => identifierSet.has(key));
}
function safeMessage(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
@@ -219,8 +265,10 @@ function summarizeFindings(findings) {
}
function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new Date().toISOString(), limit = DEFAULT_LIMIT }) {
const oaOnlySmokeIdentifierSet = buildOaOnlySmokeIdentifierSet();
const findings = {
matchedActive: [],
oaOnlySmokeExempted: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
@@ -308,13 +356,20 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
for (const employee of oaEmployees) {
if (isOaActive(employee) && !matchedOrConflictedEmployeeIds.has(employee.id)) {
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
if (isOaOnlySmokeEmployee(employee, oaOnlySmokeIdentifierSet)) {
findings.oaOnlySmokeExempted.push({
...sanitizeEmployee(employee),
reason: "oa_only_smoke_no_wecom_account_required",
});
} else {
findings.oaActiveMissingInWecom.push(sanitizeEmployee(employee));
}
}
}
const findingCounts = summarizeFindings(findings);
const findingTotal = Object.entries(findingCounts)
.filter(([key]) => key !== "matchedActive")
.filter(([key]) => !["matchedActive", "oaOnlySmokeExempted"].includes(key))
.reduce((total, [, count]) => total + count, 0);
return {
@@ -326,6 +381,7 @@ function buildConsistencyReport({ oaEmployees, wecomProfiles, generatedAt = new
oa: "active employees and wecom identity bindings",
wecom: "contact members plus hr assistant staff info",
writeBehavior: "no OA writes, no WeCom writes, no WHMS dry-run/apply API call",
oaOnlySmoke: "env-listed OA-only smoke accounts are exempt from WeCom-missing findings",
},
inputs: {
limit,
@@ -410,6 +466,7 @@ async function queryOaEmployeesReadOnly() {
function buildSkipReport({ generatedAt, reason, details, oaEmployees = [], limit }) {
const findings = {
matchedActive: [],
oaOnlySmokeExempted: [],
oaActiveMissingInWecom: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
@@ -0,0 +1,656 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { Pool } = require("pg");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const ROLE_VALUES = new Set([
"employee",
"manager",
"ceo",
"finance",
"finance_mgr",
"administration",
"hr",
"admin",
]);
const DEFAULT_TARGETS = [
{ key: "admin", identifierEnv: "LIVE_SMOKE_ADMIN_IDENTIFIER", roles: ["admin", "employee"] },
{ key: "employee", identifierEnv: "LIVE_SMOKE_EMPLOYEE_IDENTIFIER", roles: ["employee"] },
{ key: "manager", identifierEnv: "LIVE_SMOKE_MANAGER_IDENTIFIER", roles: ["manager", "employee"] },
{ key: "ceo", identifierEnv: "LIVE_SMOKE_CEO_IDENTIFIER", roles: ["ceo", "employee"] },
{ key: "finance", identifierEnv: "LIVE_SMOKE_FINANCE_IDENTIFIER", roles: ["finance", "employee"] },
{ key: "finance_mgr", identifierEnv: "LIVE_SMOKE_FINANCE_MGR_IDENTIFIER", roles: ["finance", "finance_mgr", "employee"] },
{ key: "hr", identifierEnv: "LIVE_SMOKE_HR_IDENTIFIER", roles: ["hr", "employee"] },
];
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function printHelp() {
process.stdout.write(`Usage: node src/scripts/repair-oa-only-smoke-accounts.js [options]
治理 OA-only smoke 账号。默认 dry-run;真实修复必须同时指定 --apply 且设置 OA_ONLY_SMOKE_REPAIR_APPLY=1。
Options:
--dry-run 只生成计划,不写数据库,默认。
--apply 执行受控 OA DB 修复,需 OA_ONLY_SMOKE_REPAIR_APPLY=1。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 输出目录,默认 artifacts/qa-flow/oa-only-smoke-repair-<timestamp>。
--target=<key>:<identifier>:<role+role>
追加一个目标;identifier 可为 employee_no/email/phone。
--help 显示帮助。
环境变量:
LIVE_SMOKE_*_IDENTIFIER 默认目标来源;不会读取或输出密码。
OA_ONLY_SMOKE_TARGETS_JSON 可选 JSON 数组,元素形如 {"key":"admin","identifier":"...","roles":["admin","employee"]}。
隐私边界:
输出只包含目标 key、脱敏标识、员工号、状态、角色和动作。
不输出手机号、邮箱、密码哈希、密码、token、secret、完整本地路径或原始 payload。
`);
}
function splitList(value) {
return String(value ?? "")
.split(/[,+\s]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function normalizeRoleList(roles) {
const values = Array.isArray(roles) ? roles : splitList(roles);
const seen = new Set();
const result = [];
for (const raw of values) {
const role = String(raw ?? "").trim();
if (!ROLE_VALUES.has(role) || seen.has(role)) continue;
seen.add(role);
result.push(role);
}
return result.length ? result : ["employee"];
}
function normalizeIdentifier(value) {
return String(value ?? "").trim().slice(0, 256);
}
function normalizeKey(value) {
return String(value ?? "").trim().slice(0, 64) || "target";
}
function maskIdentifier(value) {
const text = normalizeIdentifier(value);
if (!text) return null;
if (text.includes("@")) {
const [local, domain] = text.split("@");
const safeLocal = local.length <= 2 ? `${local[0] || "*"}***` : `${local.slice(0, 2)}***`;
const domainParts = String(domain || "").split(".");
const safeDomain = domainParts.length > 1 ? `***.${domainParts.at(-1)}` : "***";
return `${safeLocal}@${safeDomain}`;
}
if (text.length <= 4) return "***";
return `${text.slice(0, 2)}***${text.slice(-2)}`;
}
function sanitizeEmployeeNo(value) {
return String(value ?? "").trim().slice(0, 64);
}
function sanitizeRoles(roles) {
return Array.isArray(roles)
? roles.filter((role) => typeof role === "string").sort()
: [];
}
function isSmokeLikeValue(value) {
const text = String(value ?? "").trim();
if (!text) return false;
const lower = text.toLowerCase();
const emailLocal = lower.includes("@") ? lower.split("@")[0] : lower;
return (
/^smoke[-_.]/.test(emailLocal)
|| /^oa[-_.]?smoke[-_.]/.test(emailLocal)
|| /^(SMOKE|OSMOKE|WFULL|PRELAUNCH|LAUNCHAUTO|HRWDTEST)[A-Z0-9_-]*/i.test(text)
|| /测试|试运行/.test(text)
);
}
function isSafeOaOnlySmokeTarget(target, row) {
return [
target?.identifier,
row?.employeeNo,
row?.email,
row?.name,
].some(isSmokeLikeValue);
}
function sanitizeErrorMessage(value) {
return String(value ?? "")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.slice(0, 300);
}
function parseTargetArg(value) {
const parts = String(value ?? "").split(":");
if (parts.length < 2) {
throw new Error("--target must be <key>:<identifier>:<role+role>");
}
const [key, identifier, rolesRaw = "employee"] = parts;
const normalizedIdentifier = normalizeIdentifier(identifier);
if (!normalizedIdentifier) {
throw new Error("--target identifier is empty");
}
return {
key: normalizeKey(key),
identifier: normalizedIdentifier,
roles: normalizeRoleList(rolesRaw),
source: "cli",
};
}
function parseArgs(argv) {
const options = {
mode: "dry_run",
format: "text",
reportDir: process.env.OA_ONLY_SMOKE_REPAIR_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `oa-only-smoke-repair-${timestamp()}`),
targets: [],
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--dry-run") {
options.mode = "dry_run";
} else if (arg === "--apply") {
options.mode = "apply";
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
} else if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length);
} else if (arg === "--report-dir") {
options.reportDir = argv[index + 1] || options.reportDir;
index += 1;
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
} else if (arg === "--target") {
options.targets.push(parseTargetArg(argv[index + 1] || ""));
index += 1;
} else if (arg.startsWith("--target=")) {
options.targets.push(parseTargetArg(arg.slice("--target=".length)));
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
return options;
}
function parseJsonTargets() {
const raw = String(process.env.OA_ONLY_SMOKE_TARGETS_JSON ?? "").trim();
if (!raw) return [];
let parsed = null;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid OA_ONLY_SMOKE_TARGETS_JSON: ${error.message}`);
}
if (!Array.isArray(parsed)) {
throw new Error("OA_ONLY_SMOKE_TARGETS_JSON must be a JSON array");
}
return parsed.map((item, index) => {
const identifier = normalizeIdentifier(item?.identifier);
if (!identifier) {
throw new Error(`OA_ONLY_SMOKE_TARGETS_JSON[${index}].identifier is empty`);
}
return {
key: normalizeKey(item?.key || `json_${index + 1}`),
identifier,
roles: normalizeRoleList(item?.roles),
source: "env_json",
};
});
}
function loadTargets(options) {
const defaults = DEFAULT_TARGETS
.map((target) => {
const identifier = normalizeIdentifier(process.env[target.identifierEnv]);
if (!identifier) return null;
return {
key: target.key,
identifier,
roles: target.roles,
source: target.identifierEnv,
};
})
.filter(Boolean);
const merged = [...defaults, ...parseJsonTargets(), ...options.targets];
const seen = new Set();
const result = [];
for (const target of merged) {
const identityKey = `${target.key}:${target.identifier.toLowerCase()}`;
if (seen.has(identityKey)) continue;
seen.add(identityKey);
result.push({
key: target.key,
identifier: target.identifier,
maskedIdentifier: maskIdentifier(target.identifier),
roles: normalizeRoleList(target.roles),
source: target.source,
});
}
return result;
}
function assertApplyAllowed(options) {
if (options.mode !== "apply") return;
if (process.env.OA_ONLY_SMOKE_REPAIR_APPLY !== "1") {
throw new Error("Refusing apply without OA_ONLY_SMOKE_REPAIR_APPLY=1");
}
}
async function queryTarget(client, identifier) {
const result = await client.query(
`
SELECT
e.id::text,
e.employee_no,
e.name,
e.email,
e.phone,
e.status::text,
e.employment_status::text,
(e.archived_at IS NOT NULL) AS archived,
e.login_failed_count,
e.login_failed_first_at IS NOT NULL AS has_login_failed_first_at,
e.login_locked_until,
COALESCE(array_remove(array_agg(er.role_code::text ORDER BY er.role_code::text), NULL), ARRAY[]::text[]) AS roles,
(
SELECT COUNT(*)::int
FROM auth_sessions s
WHERE s.employee_id = e.id
AND s.revoked_at IS NULL
) AS active_auth_sessions
FROM employees e
LEFT JOIN employee_roles er ON er.employee_id = e.id
WHERE e.employee_no = $1
OR lower(e.email) = lower($1)
OR e.phone = $1
GROUP BY e.id
ORDER BY
CASE WHEN e.status = 'active' AND e.archived_at IS NULL THEN 0 ELSE 1 END,
e.employee_no ASC
LIMIT 1
`,
[identifier],
);
const row = result.rows[0];
if (!row) return null;
return {
id: row.id,
employeeNo: sanitizeEmployeeNo(row.employee_no),
name: row.name || "",
email: row.email || "",
phone: row.phone || "",
status: row.status,
employmentStatus: row.employment_status,
archived: Boolean(row.archived),
loginFailedCount: Number(row.login_failed_count ?? 0),
hasLoginFailedFirstAt: Boolean(row.has_login_failed_first_at),
loginLocked: row.login_locked_until ? new Date(row.login_locked_until) > new Date() : false,
roles: sanitizeRoles(row.roles),
activeAuthSessions: Number(row.active_auth_sessions ?? 0),
};
}
function planForTarget(target, row) {
if (!row) {
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: null,
status: "SKIP",
reason: "employee_not_found",
actions: [],
before: null,
};
}
if (!isSafeOaOnlySmokeTarget(target, row)) {
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: row.employeeNo,
status: "BLOCKED",
reason: "unsafe_non_smoke_target",
actions: [],
missingRoles: [],
before: {
status: row.status,
employmentStatus: row.employmentStatus,
archived: row.archived,
roles: row.roles,
loginFailedCount: row.loginFailedCount,
loginLocked: row.loginLocked,
activeAuthSessions: row.activeAuthSessions,
safeOaOnlySmoke: false,
},
};
}
const actions = [];
if (row.status !== "active" || row.employmentStatus !== "active" || row.archived) {
actions.push("restore_active_employee");
}
if (row.loginFailedCount > 0 || row.hasLoginFailedFirstAt || row.loginLocked) {
actions.push("clear_login_lock");
}
const missingRoles = target.roles.filter((role) => !row.roles.includes(role));
if (missingRoles.length) {
actions.push("add_missing_roles");
}
return {
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
employeeNo: row.employeeNo,
status: actions.length ? "PLANNED" : "OK",
reason: actions.length ? "needs_repair" : "already_ready",
actions,
missingRoles,
before: {
status: row.status,
employmentStatus: row.employmentStatus,
archived: row.archived,
roles: row.roles,
loginFailedCount: row.loginFailedCount,
loginLocked: row.loginLocked,
activeAuthSessions: row.activeAuthSessions,
safeOaOnlySmoke: true,
},
};
}
async function applyTarget(client, target, plan, now) {
if (!plan.employeeNo || !plan.actions.length) {
return { ...plan, result: plan.status === "OK" ? "noop" : "skipped" };
}
const row = await queryTarget(client, target.identifier);
if (!row?.id) {
return { ...plan, result: "skipped", reason: "employee_not_found_at_apply" };
}
await client.query(
`
UPDATE employees
SET
status = 'active',
employment_status = 'active',
archived_at = NULL,
login_failed_count = 0,
login_failed_first_at = NULL,
login_locked_until = NULL,
token_version = token_version + 1,
updated_at = $2::timestamptz,
updated_by = NULL
WHERE id = $1::uuid
`,
[row.id, now.toISOString()],
);
for (const role of target.roles) {
await client.query(
`
INSERT INTO employee_roles (employee_id, role_code, created_at, created_by)
VALUES ($1::uuid, $2::"RoleCode", $3::timestamptz, NULL)
ON CONFLICT (employee_id, role_code) DO NOTHING
`,
[row.id, role, now.toISOString()],
);
}
await client.query(
`
INSERT INTO employee_lifecycle_events (id, employee_id, event_type, effective_date, payload_json, created_at, created_by)
VALUES (gen_random_uuid(), $1::uuid, 'transfer', $2::date, $3::jsonb, $4::timestamptz, NULL)
`,
[
row.id,
now.toISOString().slice(0, 10),
JSON.stringify({
source: "OSMOKE1",
reason: "oa_only_smoke_account_repair",
actions: plan.actions,
expectedRoles: target.roles,
}),
now.toISOString(),
],
);
const after = await queryTarget(client, target.identifier);
return {
...plan,
result: "applied",
after: after ? {
status: after.status,
employmentStatus: after.employmentStatus,
archived: after.archived,
roles: after.roles,
loginFailedCount: after.loginFailedCount,
loginLocked: after.loginLocked,
activeAuthSessions: after.activeAuthSessions,
} : null,
};
}
function buildNoDatabaseReport(targets) {
return {
mode: "dry_run",
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
generatedAt: new Date().toISOString(),
privacy: "target key/masked identifier/role only; no password/token/secret/full email/full phone",
reason: "missing_DATABASE_URL",
targets: targets.map((target) => ({
key: target.key,
maskedIdentifier: target.maskedIdentifier,
expectedRoles: target.roles,
source: target.source,
})),
totals: {
targets: targets.length,
plannedRepairs: 0,
appliedRepairs: 0,
missingTargets: 0,
},
};
}
function buildDatabaseUnavailableReport(targets, error) {
return {
...buildNoDatabaseReport(targets),
reason: "database_unavailable",
details: {
message: sanitizeErrorMessage(error?.message || error),
name: error?.name || null,
},
};
}
function summarizeReport({ mode, targets, planned, generatedAt }) {
const plannedRepairs = planned.filter((item) => item.status === "PLANNED").length;
const missingTargets = planned.filter((item) => item.reason === "employee_not_found").length;
const blockedTargets = planned.filter((item) => item.status === "BLOCKED").length;
const appliedRepairs = planned.filter((item) => item.result === "applied").length;
const blockers = missingTargets;
let status = "PASS_OA_ONLY_SMOKE_READY";
if (blockedTargets > 0) {
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
} else if (!targets.length) {
status = "PAUSED_MANUAL_CONFIRMATION";
} else if (mode === "apply") {
status = blockers ? "PASS_WITH_FINDINGS" : "APPLIED_OA_ONLY_SMOKE_REPAIR";
} else if (plannedRepairs > 0 || missingTargets > 0) {
status = "PASS_WITH_REPAIR_PLAN";
}
return {
mode,
status,
generatedAt,
privacy: "target key/masked identifier/employee_no/status/roles/actions only; no password/token/secret/full email/full phone",
scope: {
accountClass: "oa_only_smoke",
wecomWrites: 0,
passwordReset: false,
realEmployees: "not_targeted",
},
totals: {
targets: targets.length,
plannedRepairs,
appliedRepairs,
missingTargets,
blockedTargets,
readyTargets: planned.filter((item) => item.status === "OK").length,
},
targets: planned,
manualGate: mode === "dry_run" && plannedRepairs > 0
? "A:按 dry-run 结果恢复现有 OA-only smoke;B:另建新账号;C:暂停自动化。"
: null,
};
}
async function execute(options) {
const targets = loadTargets(options);
assertApplyAllowed(options);
if (!targets.length) {
return summarizeReport({
mode: options.mode,
targets,
planned: [],
generatedAt: new Date().toISOString(),
});
}
if (!process.env.DATABASE_URL) {
return buildNoDatabaseReport(targets);
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
let client;
try {
client = await pool.connect();
} catch (error) {
await pool.end();
if (options.mode !== "apply") {
return buildDatabaseUnavailableReport(targets, error);
}
throw error;
}
const generatedAt = new Date().toISOString();
const now = new Date();
try {
await client.query(options.mode === "apply" ? "BEGIN" : "BEGIN READ ONLY");
const planned = [];
for (const target of targets) {
const row = await queryTarget(client, target.identifier);
const plan = planForTarget(target, row);
planned.push(plan);
}
if (options.mode === "apply" && planned.some((item) => item.status === "BLOCKED")) {
await client.query("ROLLBACK");
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
}
if (options.mode === "apply") {
const applied = [];
for (let index = 0; index < targets.length; index += 1) {
applied.push(await applyTarget(client, targets[index], planned[index], now));
}
planned.splice(0, planned.length, ...applied);
}
await client.query("COMMIT");
return summarizeReport({ mode: options.mode, targets, planned, generatedAt });
} catch (error) {
try {
await client.query("ROLLBACK");
} catch {
// Keep original error.
}
throw error;
} finally {
client.release();
await pool.end();
}
}
function ensureReportDir(reportDir) {
fs.mkdirSync(reportDir, { recursive: true });
}
function writeReport(reportDir, report) {
ensureReportDir(reportDir);
const filePath = path.join(reportDir, "summary.json");
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
return filePath;
}
function printText(report, reportPath) {
process.stdout.write(`[oa-only-smoke-repair] status=${report.status} mode=${report.mode}\n`);
process.stdout.write(`[oa-only-smoke-repair] report=${reportPath}\n`);
process.stdout.write(`[oa-only-smoke-repair] totals=${JSON.stringify(report.totals)}\n`);
if (report.manualGate) {
process.stdout.write(`[oa-only-smoke-repair] manualGate=${report.manualGate}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await execute(options);
const reportPath = writeReport(options.reportDir, report);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
} else {
printText(report, reportPath);
}
if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
process.exitCode = 1;
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[oa-only-smoke-repair][FAIL] ${sanitizeErrorMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
DEFAULT_TARGETS,
execute,
isSafeOaOnlySmokeTarget,
isSmokeLikeValue,
loadTargets,
normalizeRoleList,
parseArgs,
planForTarget,
};
@@ -23,6 +23,8 @@ Options:
--dry-run 只生成计划,不写数据库,默认。
--apply 执行受控 OA 写入,需 WOUA_FIX_APPLY=1。
--allow-employee-no=<no[,no]> 限定允许处理的员工号;可重复。
--oa-only-smoke-employee-no=<no[,no]>
明确标记 OA-only smoke 员工号;这些员工缺企微账号时不归档。
--clear-inactive-bindings 显式清理 inactiveWithBinding 中的绑定;默认不清理。
--repair-manager-effects 将归档测试主管的 active 下属和 pending 待办转给 fallback active manager。
--manager-fallback-employee-no=<no>
@@ -45,6 +47,7 @@ function parseArgs(argv) {
reportDir: process.env.WOUA_FIX_REPORT_DIR
|| path.join(DEFAULT_REPORT_ROOT, `wecom-oa-user-consistency-fix-${timestamp()}`),
allowEmployeeNos: [],
oaOnlySmokeEmployeeNos: splitList(process.env.OA_ONLY_SMOKE_EMPLOYEE_NOS || ""),
clearInactiveBindings: false,
repairManagerEffects: false,
managerFallbackEmployeeNo: "",
@@ -79,6 +82,11 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--allow-employee-no=")) {
options.allowEmployeeNos.push(...splitList(arg.slice("--allow-employee-no=".length)));
} else if (arg === "--oa-only-smoke-employee-no") {
options.oaOnlySmokeEmployeeNos.push(...splitList(argv[index + 1] || ""));
index += 1;
} else if (arg.startsWith("--oa-only-smoke-employee-no=")) {
options.oaOnlySmokeEmployeeNos.push(...splitList(arg.slice("--oa-only-smoke-employee-no=".length)));
} else if (arg === "--clear-inactive-bindings") {
options.clearInactiveBindings = true;
} else if (arg === "--repair-manager-effects") {
@@ -97,6 +105,7 @@ function parseArgs(argv) {
throw new Error(`Unsupported --format: ${options.format}`);
}
options.allowEmployeeNos = normalizeEmployeeNoList(options.allowEmployeeNos);
options.oaOnlySmokeEmployeeNos = normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos);
return options;
}
@@ -170,18 +179,40 @@ function buildAcceptedMissingRequiredFields(items) {
function buildFixPlan(report, options = {}) {
const findings = report?.findings ?? {};
const allowSet = new Set(normalizeEmployeeNoList(options.allowEmployeeNos));
const oaOnlySmokeSet = new Set(normalizeEmployeeNoList(options.oaOnlySmokeEmployeeNos));
const shouldInclude = (item) => !allowSet.size || allowSet.has(item.employeeNo);
const archiveCandidates = buildEmployeeNoActions(
const archiveSource = buildEmployeeNoActions(
findings.oaActiveMissingInWecom,
"archive_oa_test_employee",
"user_confirmed_oa_active_missing_in_wecom_are_test_data",
).filter(shouldInclude);
);
const oaOnlySmokeExempted = [
...buildEmployeeNoActions(
findings.oaOnlySmokeExempted,
"oa_only_smoke_exempted",
"source_report_classified_oa_only_smoke",
),
...archiveSource
.filter((item) => oaOnlySmokeSet.has(item.employeeNo))
.map((item) => ({
employeeNo: item.employeeNo,
action: "oa_only_smoke_exempted",
reason: "explicit_oa_only_smoke_employee_no",
})),
];
const exemptSet = new Set(oaOnlySmokeExempted.map((item) => item.employeeNo));
const archiveCandidates = archiveSource
.filter((item) => !exemptSet.has(item.employeeNo))
.filter(shouldInclude);
const excludedByAllowlist = allowSet.size
? buildEmployeeNoActions(
findings.oaActiveMissingInWecom,
"excluded_by_allowlist",
"allowlist_did_not_include_employee_no",
).filter((item) => !allowSet.has(item.employeeNo))
? archiveSource
.filter((item) => !exemptSet.has(item.employeeNo))
.map((item) => ({
employeeNo: item.employeeNo,
action: "excluded_by_allowlist",
reason: "allowlist_did_not_include_employee_no",
}))
.filter((item) => !allowSet.has(item.employeeNo))
: [];
const inactiveBindingAction = options.clearInactiveBindings
@@ -194,12 +225,14 @@ function buildFixPlan(report, options = {}) {
sourceGeneratedAt: report?.generatedAt ?? null,
decisions: {
oaActiveMissingInWecom: "archive_oa_test_employees",
oaOnlySmokeMissingInWecom: "exempt_no_archive",
missingRequiredFields: "accepted_no_write",
inactiveWithBinding: inactiveBindingAction,
managerSideEffects: options.repairManagerEffects ? "repair_to_active_manager" : "report_only",
wecomWriteBehavior: "no_wecom_writes",
},
archiveOaTestEmployees: archiveCandidates,
oaOnlySmokeExempted,
excludedByAllowlist,
acceptedMissingRequiredFields: buildAcceptedMissingRequiredFields(findings.missingRequiredFields),
inactiveWithBinding: buildEmployeeNoActions(
@@ -234,6 +267,7 @@ function buildDryRunResult(plan, options = {}) {
decisions: plan.decisions,
totals: {
archiveOaTestEmployees: plan.archiveOaTestEmployees.length,
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
excludedByAllowlist: plan.excludedByAllowlist.length,
acceptedMissingRequiredFields: plan.acceptedMissingRequiredFields.length,
inactiveWithBinding: plan.inactiveWithBinding.length,
@@ -243,6 +277,7 @@ function buildDryRunResult(plan, options = {}) {
},
actions: {
archiveOaTestEmployees: sample(plan.archiveOaTestEmployees),
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
excludedByAllowlist: sample(plan.excludedByAllowlist),
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
inactiveWithBinding: sample(plan.inactiveWithBinding),
@@ -610,6 +645,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
decisions: plan.decisions,
totals: {
archiveOaTestEmployees: archived.length,
oaOnlySmokeExempted: plan.oaOnlySmokeExempted.length,
archivedApplied: countByResult(archived, "applied"),
archivedPlanned: countByResult(archived, "planned"),
archivedNoop: countByResult(archived, "noop"),
@@ -629,6 +665,7 @@ function buildDatabaseResult({ plan, archived, inactiveBindings, managerRepairs
},
actions: {
archiveOaTestEmployees: sample(archived),
oaOnlySmokeExempted: sample(plan.oaOnlySmokeExempted),
excludedByAllowlist: sample(plan.excludedByAllowlist),
acceptedMissingRequiredFields: sample(plan.acceptedMissingRequiredFields),
inactiveWithBinding: sample(inactiveBindings),
@@ -64,6 +64,28 @@ describe("audit-wecom-oa-user-consistency", () => {
});
});
it("exempts default OA-only smoke accounts from WeCom-missing findings", () => {
const report = buildConsistencyReport({
oaEmployees: [
employee({
employeeNo: "T0001",
name: "OA smoke admin",
email: "[email protected]",
wecomExternalUserId: "",
}),
],
wecomProfiles: [],
});
expect(report.status).toBe("PASS_NO_DRIFT");
expect(count(report, "oaOnlySmokeExempted")).toBe(1);
expect(count(report, "oaActiveMissingInWecom")).toBe(0);
expect(report.findings.oaOnlySmokeExempted[0]).toMatchObject({
employeeNo: "T0001",
reason: "oa_only_smoke_no_wecom_account_required",
});
});
it("classifies WeCom active profiles missing from OA", () => {
const report = buildConsistencyReport({
oaEmployees: [],
@@ -0,0 +1,88 @@
const {
isSafeOaOnlySmokeTarget,
isSmokeLikeValue,
normalizeRoleList,
parseArgs,
planForTarget,
} = require("../../../src/scripts/repair-oa-only-smoke-accounts");
function target(overrides = {}) {
return {
key: overrides.key ?? "admin",
identifier: overrides.identifier ?? "[email protected]",
maskedIdentifier: "sm***@***.com",
roles: overrides.roles ?? ["admin", "employee"],
};
}
function row(overrides = {}) {
return {
id: "11111111-1111-4111-8111-111111111111",
employeeNo: overrides.employeeNo ?? "T0001",
name: overrides.name ?? "OA试运行测试管理员",
email: overrides.email ?? "[email protected]",
phone: overrides.phone ?? "",
status: overrides.status ?? "archived",
employmentStatus: overrides.employmentStatus ?? "inactive",
archived: overrides.archived ?? true,
roles: overrides.roles ?? ["employee"],
loginFailedCount: overrides.loginFailedCount ?? 3,
hasLoginFailedFirstAt: overrides.hasLoginFailedFirstAt ?? true,
loginLocked: overrides.loginLocked ?? true,
activeAuthSessions: overrides.activeAuthSessions ?? 0,
};
}
describe("repair-oa-only-smoke-accounts", () => {
afterEach(() => {
delete process.env.OA_ONLY_SMOKE_REPAIR_APPLY;
});
it("recognizes only smoke-like identifiers as safe targets", () => {
expect(isSmokeLikeValue("[email protected]")).toBe(true);
expect(isSmokeLikeValue("SMOKE002")).toBe(true);
expect(isSmokeLikeValue("OA试运行测试管理员")).toBe(true);
expect(isSmokeLikeValue("[email protected]")).toBe(false);
});
it("blocks non-smoke employee matches before planning role repair", () => {
const plan = planForTarget(
target({ identifier: "[email protected]", roles: ["admin", "employee"] }),
row({
employeeNo: "HYD000100",
name: "真实员工",
email: "[email protected]",
status: "archived",
employmentStatus: "inactive",
archived: true,
}),
);
expect(isSafeOaOnlySmokeTarget(target({ identifier: "[email protected]" }), plan.before)).toBe(false);
expect(plan.status).toBe("BLOCKED");
expect(plan.reason).toBe("unsafe_non_smoke_target");
expect(plan.actions).toEqual([]);
});
it("plans only state, lock, and missing-role repairs for safe OA-only smoke rows", () => {
const plan = planForTarget(target(), row());
expect(plan.status).toBe("PLANNED");
expect(plan.actions).toEqual(["restore_active_employee", "clear_login_lock", "add_missing_roles"]);
expect(plan.missingRoles).toEqual(["admin"]);
expect(JSON.stringify(plan)).not.toContain("[email protected]");
});
it("normalizes roles and parses apply mode without enabling apply by itself", () => {
const options = parseArgs(["--apply", "--target=admin:[email protected]:admin+employee", "--format=json"]);
expect(options.mode).toBe("apply");
expect(options.targets[0]).toMatchObject({
key: "admin",
identifier: "[email protected]",
roles: ["admin", "employee"],
});
expect(normalizeRoleList(["admin", "admin", "invalid", "employee"])).toEqual(["admin", "employee"]);
expect(process.env.OA_ONLY_SMOKE_REPAIR_APPLY).toBeUndefined();
});
});
@@ -27,6 +27,7 @@ function auditReport(overrides = {}) {
{ employeeNo: "HRWDTEST-20260517214548", status: "archived", archived: true, roles: ["employee"] },
],
matchedActive: [],
oaOnlySmokeExempted: [],
wecomActiveMissingInOa: [],
oaActiveMissingBinding: [],
statusMismatch: [],
@@ -103,6 +104,27 @@ describe("resolve-wecom-oa-user-consistency", () => {
expect(plan.excludedByAllowlist.map((item) => item.employeeNo)).toEqual(["TEST001"]);
});
it("exempts source-classified OA-only smoke accounts from archive plans", () => {
const plan = buildFixPlan(auditReport({
findings: {
...auditReport().findings,
oaOnlySmokeExempted: [
{ employeeNo: "TEST001", status: "active", roles: ["admin", "employee"] },
],
},
}));
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST002"]);
expect(plan.oaOnlySmokeExempted.map((item) => item.employeeNo)).toEqual(["TEST001"]);
});
it("supports explicit OA-only smoke employee-no exemption for legacy reports", () => {
const plan = buildFixPlan(auditReport(), { oaOnlySmokeEmployeeNos: ["TEST002"] });
expect(plan.archiveOaTestEmployees.map((item) => item.employeeNo)).toEqual(["TEST001"]);
expect(plan.oaOnlySmokeExempted.map((item) => item.employeeNo)).toEqual(["TEST002"]);
});
it("requires explicit environment approval for apply mode", () => {
expect(() => assertApplyAllowed({ mode: "apply" })).toThrow(/WOUA_FIX_APPLY=1/);
process.env.WOUA_FIX_APPLY = "1";
@@ -116,6 +138,7 @@ describe("resolve-wecom-oa-user-consistency", () => {
"--allow-employee-no=TEST001,TEST002",
"--allow-employee-no",
"TEST001",
"--oa-only-smoke-employee-no=TEST003",
"--repair-manager-effects",
"--manager-fallback-employee-no=HYD000007",
"--format=json",
@@ -125,6 +148,7 @@ describe("resolve-wecom-oa-user-consistency", () => {
expect(options.reportPath).toBe("summary.json");
expect(options.format).toBe("json");
expect(options.allowEmployeeNos).toEqual(["TEST001", "TEST002"]);
expect(options.oaOnlySmokeEmployeeNos).toEqual(["TEST003"]);
expect(options.repairManagerEffects).toBe(true);
expect(options.managerFallbackEmployeeNo).toBe("HYD000007");
});