WCBK1 实现企微审批回调只读 POC
这个提交包含在:
@@ -18,7 +18,7 @@ Read only what is relevant:
|
|||||||
- For HR lifecycle, employee onboarding/offboarding, Enterprise WeChat directory sync, account disablement, or Enterprise WeChat API work, read `docs/需求-企业微信基础设施规约.md`, `docs/企微API能力目录.md`, `docs/管理员手册.md`, and the latest HCLT/HRWD/WAPI/WRRA Issue CSV evidence first.
|
- For HR lifecycle, employee onboarding/offboarding, Enterprise WeChat directory sync, account disablement, or Enterprise WeChat API work, read `docs/需求-企业微信基础设施规约.md`, `docs/企微API能力目录.md`, `docs/管理员手册.md`, and the latest HCLT/HRWD/WAPI/WRRA Issue CSV evidence first.
|
||||||
- For any Enterprise WeChat capability, Secret, callback, approval, check-in, HR assistant, or contact-directory task, read `docs/企微API能力目录.md` before planning or coding. WAPI fixes the current Secret matrix: contacts use `WECOM_CONTACT_SECRET`; workbench/OAuth/messages/HR assistant/roster/check-in/approval use `WECOM_AGENT_SECRET`; `WECOM_CHECKIN_SECRET` is only an explicit check-in override; `WECOM_HR_ASSISTANT_SECRET` is historical compatibility and not a default. Do not write runtime code that tries multiple Secrets to guess the correct one.
|
- For any Enterprise WeChat capability, Secret, callback, approval, check-in, HR assistant, or contact-directory task, read `docs/企微API能力目录.md` before planning or coding. WAPI fixes the current Secret matrix: contacts use `WECOM_CONTACT_SECRET`; workbench/OAuth/messages/HR assistant/roster/check-in/approval use `WECOM_AGENT_SECRET`; `WECOM_CHECKIN_SECRET` is only an explicit check-in override; `WECOM_HR_ASSISTANT_SECRET` is historical compatibility and not a default. Do not write runtime code that tries multiple Secrets to guess the correct one.
|
||||||
- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. WPROC1 fixes the engineering route: WAPC2 must be followed by WAPC2F automatic POC fixtures, WAPC2 rerun, WCBK1 callback POC, WACP3 compensation-pull POC, and WADM1 mirror data model design before `wecom_attendance_approval_mirror_v1`. WPROC2 adds the executable `process gate`: before WAPC2F or other high-risk WeCom work, run `node scripts/qa/check-process-contract.mjs --baseline` and the issue-specific profile check to enforce DoR/DoD, fixed Secret source, non-self approval, live write switches, missing-config classification, and redaction boundaries. WAPC2F has implemented and deployed `npm run check:wecom-approval-fixtures`; public dry-run reached `PASS_FIXTURE_DRY_RUN_READY` at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json`, but no real WeCom template or approval was created because `WECOM_APPROVAL_FIXTURE_WRITE=1` was not set. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV.
|
- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. WPROC1 fixes the engineering route: WAPC2 must be followed by WAPC2F automatic POC fixtures, WAPC2 rerun, WCBK1 callback POC, WACP3 compensation-pull POC, and WADM1 mirror data model design before `wecom_attendance_approval_mirror_v1`. WPROC2 adds the executable `process gate`: before WAPC2F or other high-risk WeCom work, run `node scripts/qa/check-process-contract.mjs --baseline` and the issue-specific profile check to enforce DoR/DoD, fixed Secret source, non-self approval, live write switches, missing-config classification, and redaction boundaries. WAPC2F has implemented and deployed `npm run check:wecom-approval-fixtures`; public dry-run reached `PASS_FIXTURE_DRY_RUN_READY` at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json`, but no real WeCom template or approval was created because `WECOM_APPROVAL_FIXTURE_WRITE=1` was not set. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without the next stage's own Issue CSV.
|
||||||
- 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`. This still does not clear real callback, compensation pull, mirror data model, emergency mode, writeback, or RuleOps.
|
- 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. Public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` is `PARTIAL_CALLBACK_NEEDS_CONFIG`: synthetic signature verification, AES decrypt, and event parsing are OK, but `WECOM_APPROVAL_CALLBACK_URL`, `WECOM_CALLBACK_TOKEN`, and `WECOM_CALLBACK_ENCODING_AES_KEY` are not configured remotely, so real WeCom URL verification and approval event confirmation are not cleared. This still does not clear compensation pull, mirror data model, emergency mode, writeback, or RuleOps.
|
||||||
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
|
- 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`.
|
- For local/WeCom/public trial-run, OCR runtime, Caddy, or DB drift work, read `docs/试运行与自动化验证手册.md` and `references/trialrun.md`.
|
||||||
- For how this skill should evolve, read `docs/Agent开发交付实践.md`.
|
- For how this skill should evolve, read `docs/Agent开发交付实践.md`.
|
||||||
@@ -114,6 +114,7 @@ Final responses should include:
|
|||||||
- WPROC1 fixes the WeCom approval engineering process. Future WeCom work must satisfy route gate, capability gate, and implementation gate; use automatic runners before asking the user to do manual work. WPROC2 makes this executable through `node scripts/qa/check-process-contract.mjs --baseline` plus issue-specific profiles such as `--profile wecom-approval-fixture`; run it before WAPC2F and later high-risk WeCom contracts. Manual work is limited to write approval, secrets/callback config, WeCom backend settings that cannot be API-driven, real-device confirmation, and risk decisions. High-risk stages require POC before implementation: callback signature/decryption/idempotency, compensation pull, mirror data model, then mirror implementation.
|
- WPROC1 fixes the WeCom approval engineering process. Future WeCom work must satisfy route gate, capability gate, and implementation gate; use automatic runners before asking the user to do manual work. WPROC2 makes this executable through `node scripts/qa/check-process-contract.mjs --baseline` plus issue-specific profiles such as `--profile wecom-approval-fixture`; run it before WAPC2F and later high-risk WeCom contracts. Manual work is limited to write approval, secrets/callback config, WeCom backend settings that cannot be API-driven, real-device confirmation, and risk decisions. High-risk stages require POC before implementation: callback signature/decryption/idempotency, compensation pull, mirror data model, then mirror implementation.
|
||||||
- WAPC2F has added the WeCom approval fixture dry-run runner. Use `npm run check:wecom-approval-fixtures`; it defaults to applicant `SMOKE002`, approver `SMOKE101`, fixed `WECOM_AGENT_SECRET`, minimal generic OA-POC leave/missed-punch template payloads, and no write token or WeCom writes. Real fixture creation requires `--live --create-templates --submit-test-approvals` plus one-process `WECOM_APPROVAL_FIXTURE_WRITE=1` and explicit user approval. The public dry-run report is `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` with `PASS_FIXTURE_DRY_RUN_READY`.
|
- WAPC2F has added the WeCom approval fixture dry-run runner. Use `npm run check:wecom-approval-fixtures`; it defaults to applicant `SMOKE002`, approver `SMOKE101`, fixed `WECOM_AGENT_SECRET`, minimal generic OA-POC leave/missed-punch template payloads, and no write token or WeCom writes. Real fixture creation requires `--live --create-templates --submit-test-approvals` plus one-process `WECOM_APPROVAL_FIXTURE_WRITE=1` and explicit user approval. The public dry-run report is `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` with `PASS_FIXTURE_DRY_RUN_READY`.
|
||||||
- WAPC2FLIVE has executed that approved live path once after fixing the template payload shape; current public evidence is `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json`, `PASS_FIXTURE_CREATED`, `checks.autoRerun.status=OK`, and `PASS_APPROVAL_POC`. Do not create more approval fixtures without a new Issue CSV and explicit approval.
|
- WAPC2FLIVE has executed that approved live path once after fixing the template payload shape; current public evidence is `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json`, `PASS_FIXTURE_CREATED`, `checks.autoRerun.status=OK`, and `PASS_APPROVAL_POC`. Do not create more approval fixtures without a new Issue CSV and explicit approval.
|
||||||
|
- WCBK1 has implemented the WeCom approval callback read-only POC path. Use `npm run check:wecom-approval-callback-poc`; default synthetic mode verifies local encrypted callback samples, while `--live` checks the configured public callback URL and redacted event evidence. The route uses only `WECOM_APPROVAL_CALLBACK_URL`, `WECOM_CALLBACK_TOKEN`, `WECOM_CALLBACK_ENCODING_AES_KEY`, and `WECOM_CORP_ID`; do not use contact Secret or legacy callback env names. Current public status is `PARTIAL_CALLBACK_NEEDS_CONFIG` because the real callback URL, Token, and EncodingAESKey are not yet configured. After admin configuration, expect `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` handles an existing OA-POC approval and the event is received, expect `PASS_CALLBACK_EVENT_CONFIRMED`. Do not store raw XML, ciphertext, full `sp_no`, full userid, Token, EncodingAESKey, form values, attachments, comments, or raw payload.
|
||||||
- Approval-chain correctness can be proven by PC or automation, while Enterprise WeChat validates entry, binding,免登录, notification, deeplink, and mobile layout.
|
- Approval-chain correctness can be proven by PC or automation, while Enterprise WeChat validates entry, binding,免登录, notification, deeplink, and mobile layout.
|
||||||
- If real-device mobile validation is paused after automation passes, record the gate as `PAUSED`; do not close RS6/UI6/R6 mobile blockers until representative real-device confirmation is recorded.
|
- If real-device mobile validation is paused after automation passes, record the gate as `PAUSED`; do not close RS6/UI6/R6 mobile blockers until representative real-device confirmation is recorded.
|
||||||
- Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views.
|
- Role-specific pages should use one shared OA/mobile framework and tailor summaries, entries, actions, and sensitive fields by role. Do not create unrelated UI systems for employee, approver, finance, admin, or CEO views.
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ WECOM_CONTACT_DEFAULT_DEPARTMENT_ID=
|
|||||||
WECOM_HR_MASTER_SYNC_ENABLED=false
|
WECOM_HR_MASTER_SYNC_ENABLED=false
|
||||||
# 企业微信 HR 主数据镜像 Secret:人事助手专用 Secret 优先;留空时仅可复用已验证授权应用的 WECOM_AGENT_SECRET
|
# 企业微信 HR 主数据镜像 Secret:人事助手专用 Secret 优先;留空时仅可复用已验证授权应用的 WECOM_AGENT_SECRET
|
||||||
WECOM_HR_ASSISTANT_SECRET=
|
WECOM_HR_ASSISTANT_SECRET=
|
||||||
|
# 企业微信审批回调 POC:真实值只配置在部署环境,不写入仓库、Issue 或聊天
|
||||||
|
WECOM_APPROVAL_CALLBACK_URL=https://oa.example.com/api/wecom/approval/callback
|
||||||
|
WECOM_CALLBACK_TOKEN=
|
||||||
|
WECOM_CALLBACK_ENCODING_AES_KEY=
|
||||||
|
WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE=
|
||||||
IM_NOTIFIER_ENABLED=true
|
IM_NOTIFIER_ENABLED=true
|
||||||
|
|
||||||
# ===== 数据库(Compose 内部连接) =====
|
# ===== 数据库(Compose 内部连接) =====
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
- 2026-05-19 WAPC2 新增企微审批模板与低风险 `sp_no` 只读 POC:新增 `server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc` 和只读集成服务,范围先锁定请假、补卡;固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/gettemplatedetail` 与 `POST /cgi-bin/oa/getapprovaldetail`,报告只输出脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和错误分类。本地 focused vitest 已覆盖 Secret 固定选择、无 fallback、缺配置、API 错误分类和脱敏输出。已以 `SYNC_ENV=0` 部署并远端执行 live 只读 POC,报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 结论为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`:远端 `WECOM_AGENT_SECRET` 可用,但缺请假/补卡模板 ID 和两条低风险 `sp_no`;未伪造成 PASS。本轮不接真实回调、不调用企微写接口、不写 OA/企微数据、不进入深度镜像。
|
- 2026-05-19 WAPC2 新增企微审批模板与低风险 `sp_no` 只读 POC:新增 `server/src/scripts/check-wecom-approval-poc.js` / `npm run check:wecom-approval-poc` 和只读集成服务,范围先锁定请假、补卡;固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/gettemplatedetail` 与 `POST /cgi-bin/oa/getapprovaldetail`,报告只输出脱敏模板 ID/`sp_no` 前缀、字段类型计数、审批节点计数和错误分类。本地 focused vitest 已覆盖 Secret 固定选择、无 fallback、缺配置、API 错误分类和脱敏输出。已以 `SYNC_ENV=0` 部署并远端执行 live 只读 POC,报告 `/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` 结论为 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`:远端 `WECOM_AGENT_SECRET` 可用,但缺请假/补卡模板 ID 和两条低风险 `sp_no`;未伪造成 PASS。本轮不接真实回调、不调用企微写接口、不写 OA/企微数据、不进入深度镜像。
|
||||||
- 2026-05-19 WAPC2F 新增企微审批 POC 样件自动生成 dry-run:新增 `server/src/scripts/check-wecom-approval-fixtures.js` / `npm run check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET` 构建 OA-POC 请假/补卡最小通用模板候选和低风险审批申请候选;默认 dry-run 不获取写入 token、不调用企微写接口、不创建模板、不提交审批单。已以 `SYNC_ENV=0` 部署并远端执行 dry-run,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 结论为 `PASS_FIXTURE_DRY_RUN_READY`:`SMOKE002 -> SMOKE101` 均 active、已绑定企微、非自审,payload 脱敏输出通过。live 创建 OA-POC 测试模板和测试审批单仍未批准、未执行;WAPC2 自动复跑需等 live 生成真实模板 ID 和 `sp_no` 后再执行。
|
- 2026-05-19 WAPC2F 新增企微审批 POC 样件自动生成 dry-run:新增 `server/src/scripts/check-wecom-approval-fixtures.js` / `npm run check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET` 构建 OA-POC 请假/补卡最小通用模板候选和低风险审批申请候选;默认 dry-run 不获取写入 token、不调用企微写接口、不创建模板、不提交审批单。已以 `SYNC_ENV=0` 部署并远端执行 dry-run,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 结论为 `PASS_FIXTURE_DRY_RUN_READY`:`SMOKE002 -> SMOKE101` 均 active、已绑定企微、非自审,payload 脱敏输出通过。live 创建 OA-POC 测试模板和测试审批单仍未批准、未执行;WAPC2 自动复跑需等 live 生成真实模板 ID 和 `sp_no` 后再执行。
|
||||||
- 2026-05-19 WAPC2FLIVE 完成企微审批 OA-POC 样件 live 创建和 WAPC2 自动复跑:用户批准一次 live 创建并选择保留审计后,先以 `SYNC_ENV=0` 部署修正后的 runner,再远端 dry-run 复核 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053335Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`。首次 live 写入 API 因旧 `create_template` payload 返回 `301086 zh_CN text empty`,未创建任何模板或审批单;随后将 payload 修正为官方 `template_name` + `property` controls 结构并复跑有效 live,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,请假/补卡 OA-POC 测试模板和低风险测试审批单均创建成功,`checks.autoRerun.status=OK` 且结论 `PASS_APPROVAL_POC`。报告只记录脱敏 template_id / `sp_no` 前缀;测试模板和测试审批记录保留审计,不自动删除。本轮不配置真实回调、不做补偿拉取、不建镜像表、不进入 `wecom_attendance_approval_mirror_v1`。
|
- 2026-05-19 WAPC2FLIVE 完成企微审批 OA-POC 样件 live 创建和 WAPC2 自动复跑:用户批准一次 live 创建并选择保留审计后,先以 `SYNC_ENV=0` 部署修正后的 runner,再远端 dry-run 复核 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053335Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`。首次 live 写入 API 因旧 `create_template` payload 返回 `301086 zh_CN text empty`,未创建任何模板或审批单;随后将 payload 修正为官方 `template_name` + `property` controls 结构并复跑有效 live,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,请假/补卡 OA-POC 测试模板和低风险测试审批单均创建成功,`checks.autoRerun.status=OK` 且结论 `PASS_APPROVAL_POC`。报告只记录脱敏 template_id / `sp_no` 前缀;测试模板和测试审批记录保留审计,不自动删除。本轮不配置真实回调、不做补偿拉取、不建镜像表、不进入 `wecom_attendance_approval_mirror_v1`。
|
||||||
|
- 2026-05-19 WCBK1 实现并部署企微审批回调只读 POC:新增只读 `GET/POST /api/wecom/approval/callback`、企微回调验签/AES 解密/事件归一化服务、`npm run check:wecom-approval-callback-poc` 和 `wecom-callback` process gate。已以 `SYNC_ENV=0` 部署并复检 `/ready`;远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` 为 `PARTIAL_CALLBACK_NEEDS_CONFIG`:synthetic 回调 `msg_signature` 验签、EncodingAESKey 解密和审批事件解析通过,但远端未配置 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,尚未证明企微后台真实 URL 验证或真实审批事件回调。本轮不建镜像表、不写 OA 业务数据、不写企微数据、不创建新模板或审批单。
|
||||||
|
|
||||||
## 关键证据索引
|
## 关键证据索引
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
|||||||
| WAPC2 | 已部署并公网只读复跑:新增请假/补卡模板详情和低风险审批详情 QA-only 只读 POC runner;远端 Secret 矩阵 OK,因缺请假/补卡模板 ID 和两条低风险 `sp_no` 返回 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;不代表真实回调或审批镜像已实现 | `issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv`、`server/src/scripts/check-wecom-approval-poc.js`、`server/src/services/integrations/wecomApproval.js`、`/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` |
|
| WAPC2 | 已部署并公网只读复跑:新增请假/补卡模板详情和低风险审批详情 QA-only 只读 POC runner;远端 Secret 矩阵 OK,因缺请假/补卡模板 ID 和两条低风险 `sp_no` 返回 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`;不代表真实回调或审批镜像已实现 | `issues/2026-05-19_09-34-16-wecom-approval-template-spno-poc-v1.csv`、`server/src/scripts/check-wecom-approval-poc.js`、`server/src/services/integrations/wecomApproval.js`、`/app/artifacts/qa-flow/wecom-approval-poc-20260519T020429Z/summary.json` |
|
||||||
| WAPC2F | 已部署并公网 dry-run 通过:新增企微审批 POC 样件 runner;远端 dry-run 报告为 `PASS_FIXTURE_DRY_RUN_READY`,确认 `SMOKE002 -> SMOKE101` 非自审链、Secret 矩阵和脱敏报告边界 | `issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv`、`server/src/scripts/check-wecom-approval-fixtures.js`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` |
|
| WAPC2F | 已部署并公网 dry-run 通过:新增企微审批 POC 样件 runner;远端 dry-run 报告为 `PASS_FIXTURE_DRY_RUN_READY`,确认 `SMOKE002 -> SMOKE101` 非自审链、Secret 矩阵和脱敏报告边界 | `issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv`、`server/src/scripts/check-wecom-approval-fixtures.js`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` |
|
||||||
| WAPC2FLIVE | 已部署并完成 live 创建:修正 `create_template` payload 后,远端报告 `PASS_FIXTURE_CREATED`,OA-POC 请假/补卡测试模板和低风险测试审批单已创建并保留审计;自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。不代表真实回调、补偿拉取、镜像模型或深度镜像已完成 | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` |
|
| WAPC2FLIVE | 已部署并完成 live 创建:修正 `create_template` payload 后,远端报告 `PASS_FIXTURE_CREATED`,OA-POC 请假/补卡测试模板和低风险测试审批单已创建并保留审计;自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。不代表真实回调、补偿拉取、镜像模型或深度镜像已完成 | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` |
|
||||||
|
| WCBK1 | 已部署代码并完成公网 dry-run:只读回调端点和 synthetic runner 通过本地加密链路;远端报告 `PARTIAL_CALLBACK_NEEDS_CONFIG`,缺真实回调 URL/Token/EncodingAESKey 配置,尚未证明企微后台 URL 验证或真实事件回调 | `issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` |
|
||||||
|
|
||||||
## 当前门禁
|
## 当前门禁
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -21,7 +21,7 @@
|
|||||||
- 企业微信打卡用于原始采集,保留其定位、人脸、Wi-Fi、规则与设备能力。
|
- 企业微信打卡用于原始采集,保留其定位、人脸、Wi-Fi、规则与设备能力。
|
||||||
- OA 后续同步企业微信打卡规则、排班、记录、日报、月报和 HR/考勤审批镜像,形成正式考勤口径。
|
- OA 后续同步企业微信打卡规则、排班、记录、日报、月报和 HR/考勤审批镜像,形成正式考勤口径。
|
||||||
- 请假、补卡、外出、加班等考勤修正正常在企业微信完成;OA 只在应急模式下接管,并在企微恢复后 dry-run 和自动回写。
|
- 请假、补卡、外出、加班等考勤修正正常在企业微信完成;OA 只在应急模式下接管,并在企微恢复后 dry-run 和自动回写。
|
||||||
- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余企微缺字段先接受不补,归档绑定保留审计。后续考勤/审批镜像、应急接管、恢复回写或 RuleOps 仍必须先完成 WAPC1、WAPC2、WAPC2F、WCBK1、WACP3 和 WADM1,再另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。
|
- `wecom_hr_master_sync_v1` 已本地实现并部署公网完成受控验证;最终保持 `force_off`。WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 OA active 5、企微 active 5、匹配 5、OA active 缺企微 0;剩余企微缺字段先接受不补,归档绑定保留审计。WCBK1 已部署审批回调只读端点和 synthetic runner,但真实回调 URL/Token/EncodingAESKey 尚未配置。后续考勤/审批镜像、应急接管、恢复回写或 RuleOps 仍必须先完成 WCBK1 真实 URL/事件确认、WACP3 和 WADM1,再另建 `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 或 `wecom_ruleops_v1` 合同。
|
||||||
|
|
||||||
### P0-3 按域镜像定义
|
### P0-3 按域镜像定义
|
||||||
- 企微主办域镜像到 OA:通讯录/人事助手、打卡、请假、补卡、外出、加班、假期余额和 HR/考勤规则。
|
- 企微主办域镜像到 OA:通讯录/人事助手、打卡、请假、补卡、外出、加班、假期余额和 HR/考勤规则。
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
### P0-5 WDRG 开发路线图门禁
|
### P0-5 WDRG 开发路线图门禁
|
||||||
- WDRG 后采用“路线图门禁 + 单功能 Issue CSV”:路线图只控制阶段顺序、准入/退出门禁、并行规则和禁止跳阶段,具体功能仍单独方案、单独 Issue CSV、单独验证。
|
- WDRG 后采用“路线图门禁 + 单功能 Issue CSV”:路线图只控制阶段顺序、准入/退出门禁、并行规则和禁止跳阶段,具体功能仍单独方案、单独 Issue CSV、单独验证。
|
||||||
- 当前企微优先 HR/考勤路线顺序固定为:`WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WACP3 补偿拉取 POC` -> `WADM1 镜像数据模型设计` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
- 当前企微优先 HR/考勤路线顺序固定为:`WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2 自动复跑` -> `WCBK1 回调只读 POC(代码/synthetic 已部署,真实配置待补)` -> `WACP3 补偿拉取 POC` -> `WADM1 镜像数据模型设计` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||||
- `WECOM-CAPABILITY-POC-V1` 已由 WCPC 收口,WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证,WOUA-FIX 已完成测试账号差异收口;WRV1/WRA1/WMSG1 固定运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡只读门禁。后续不得跳过 WAPC2F 自动样件、真实回调 POC、补偿拉取 POC 和镜像数据模型设计,直接开发考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。
|
- `WECOM-CAPABILITY-POC-V1` 已由 WCPC 收口,WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证,WOUA-FIX 已完成测试账号差异收口;WRV1/WRA1/WMSG1 固定运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡只读门禁。后续不得跳过 WAPC2F 自动样件、真实回调 POC、补偿拉取 POC 和镜像数据模型设计,直接开发考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。
|
||||||
- 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可以另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。
|
- 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可以另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。
|
||||||
- 路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md);该路线图不代表任何运行时迁移能力已完成。
|
- 路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md);该路线图不代表任何运行时迁移能力已完成。
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
- WHMSLIVE 公网验证中,`SMOKE002/测试员工`(脱敏 `oa_smoke***`)因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,证明缺字段不会写入占位数据;普通员工访问同步 API 已验证为 403。
|
- WHMSLIVE 公网验证中,`SMOKE002/测试员工`(脱敏 `oa_smoke***`)因企微侧缺手机号、邮箱和部门按 `missing_required_field` 跳过,证明缺字段不会写入占位数据;普通员工访问同步 API 已验证为 403。
|
||||||
- 已有 OA 员工只更新安全主数据字段,不覆盖银行账户、身份证、工资、合同附件或历史审批。
|
- 已有 OA 员工只更新安全主数据字段,不覆盖银行账户、身份证、工资、合同附件或历史审批。
|
||||||
- WAPI 后 WHMS/WOUA 读取策略修正为通讯录 `user/list_id` + 可选成员详情 + 人事助手 `get_staff_info`,通讯录用 `WECOM_CONTACT_SECRET`,人事助手用 `WECOM_AGENT_SECRET`,不再靠 fallback 试错。
|
- WAPI 后 WHMS/WOUA 读取策略修正为通讯录 `user/list_id` + 可选成员详情 + 人事助手 `get_staff_info`,通讯录用 `WECOM_CONTACT_SECRET`,人事助手用 `WECOM_AGENT_SECRET`,不再靠 fallback 试错。
|
||||||
- WHMS/WOUA-FIX 不代表考勤/审批镜像、审批回调、OA 应急接管、恢复回写或 RuleOps 已完成;WRA1 后 WRV1 的 `PASS_WITH_CONFIG_GAPS` 只代表运行优先主链路可用且仍有审批/回调配置缺口;WMSG1 只证明消息和 Deeplink 可用;WAPC1 只证明审批配置准备清单已固化;WAPC2 只证明只读 runner 已具备。WPROC1 后,下一步先做 WAPC2F 自动样件、WAPC2 自动复跑、WCBK1、WACP3 和 WADM1,不直接另建 `wecom_attendance_approval_mirror_v1`。
|
- WHMS/WOUA-FIX 不代表考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成;WRA1 后 WRV1 的 `PASS_WITH_CONFIG_GAPS` 只代表运行优先主链路可用且仍有审批/回调配置缺口;WMSG1 只证明消息和 Deeplink 可用;WAPC1 只证明审批配置准备清单已固化;WAPC2 只证明只读 runner 已具备;WAPC2FLIVE 只证明 OA-POC 样件可创建并可读;WCBK1 当前只证明回调代码链路、验签/解密/解析 synthetic 可用,真实 URL 验证和真实事件确认仍待管理员配置后复跑。后续先做 WCBK1 真实确认、WACP3 和 WADM1,不直接另建 `wecom_attendance_approval_mirror_v1`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
**文档状态**:当前企微 API 调研与本项目使用事实源。
|
**文档状态**:当前企微 API 调研与本项目使用事实源。
|
||||||
**分类**:集成事实源 / API 能力目录。
|
**分类**:集成事实源 / API 能力目录。
|
||||||
**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE 之后。
|
**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 之后。
|
||||||
**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE`。
|
**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1`。
|
||||||
**最近核对**:2026-05-19。
|
**最近核对**:2026-05-19。
|
||||||
**不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。
|
**不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
| 审批概述 | [审批](https://developer.work.weixin.qq.com/document/path/91854) | 模板、实例、回调、假期 | 混合 | `WECOM_AGENT_SECRET` | `PARTIAL_NEEDS_CONFIG` | HR/考勤员工日常审批可优先在企微处理,但 OA 镜像尚未实现。 |
|
| 审批概述 | [审批](https://developer.work.weixin.qq.com/document/path/91854) | 模板、实例、回调、假期 | 混合 | `WECOM_AGENT_SECRET` | `PARTIAL_NEEDS_CONFIG` | HR/考勤员工日常审批可优先在企微处理,但 OA 镜像尚未实现。 |
|
||||||
| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读模板详情;WAPC2FLIVE 已自动创建 OA-POC 请假/补卡测试模板并复跑得到 `PASS_APPROVAL_POC`。正式模板和外出/加班仍需单独配置或 POC。 |
|
| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读模板详情;WAPC2FLIVE 已自动创建 OA-POC 请假/补卡测试模板并复跑得到 `PASS_APPROVAL_POC`。正式模板和外出/加班仍需单独配置或 POC。 |
|
||||||
| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读审批详情;WAPC2FLIVE 已提交低风险请假/补卡测试审批单并自动复跑通过。测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 |
|
| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读审批详情;WAPC2FLIVE 已提交低风险请假/补卡测试审批单并自动复跑通过。测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 |
|
||||||
| 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `POST` 事件 | `WECOM_AGENT_SECRET` 所属应用 | `NEEDS_CONFIG` | 需要真实回调 URL、Token、EncodingAESKey、验签、解密、幂等和重试设计;文档和报告只记录配置项名称,不保存密钥值。 |
|
| 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `GET` URL 验证 / `POST` 事件 | 自建应用回调 Token/EncodingAESKey | `PARTIAL_CALLBACK_NEEDS_CONFIG` | WCBK1 已部署只读 `GET/POST /api/wecom/approval/callback` 和 `check:wecom-approval-callback-poc`;公网 synthetic 验签、解密和解析通过,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json`。仍缺 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY` 真实配置,未证明企微后台 URL 验证或真实审批事件回调。 |
|
||||||
| 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | 官方审批单查询接口 | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_API_POC` | 作为补偿拉取候选,WAPC1 后续 POC 前不得假定可用。 |
|
| 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | 官方审批单查询接口 | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_API_POC` | 作为补偿拉取候选,WAPC1 后续 POC 前不得假定可用。 |
|
||||||
| 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | `/cgi-bin/oa/applyevent` | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | 已接入 WAPC2F QA-only runner;仅可用于 OA-POC 低风险请假/补卡测试审批单自动样件。默认 dry-run 不获取写入 token、不提交审批;live 写入需显式 CLI、`WECOM_APPROVAL_FIXTURE_WRITE=1` 和人工批准,申请人/审批人必须非自审。WAPC2FLIVE 已提交请假/补卡测试审批单并保留审计;OA 主办报销、采购、合同、项目不迁到企微原生审批。 |
|
| 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | `/cgi-bin/oa/applyevent` | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | 已接入 WAPC2F QA-only runner;仅可用于 OA-POC 低风险请假/补卡测试审批单自动样件。默认 dry-run 不获取写入 token、不提交审批;live 写入需显式 CLI、`WECOM_APPROVAL_FIXTURE_WRITE=1` 和人工批准,申请人/审批人必须非自审。WAPC2FLIVE 已提交请假/补卡测试审批单并保留审计;OA 主办报销、采购、合同、项目不迁到企微原生审批。 |
|
||||||
| 假期余额 | [获取成员假期余额](https://developer.work.weixin.qq.com/document/path/93376) | `/cgi-bin/oa/vacation/getuservacationquota` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_OR_NEEDS_PERMISSION` | WCPC 已证明可读但历史有权限错误;后续作为考勤镜像的只读项。 |
|
| 假期余额 | [获取成员假期余额](https://developer.work.weixin.qq.com/document/path/93376) | `/cgi-bin/oa/vacation/getuservacationquota` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_OR_NEEDS_PERMISSION` | WCPC 已证明可读但历史有权限错误;后续作为考勤镜像的只读项。 |
|
||||||
@@ -72,7 +72,7 @@ WAPC1 后,企微审批相关能力进入“配置准备”状态。该状态
|
|||||||
- 首批模板:请假、补卡、外出、加班。
|
- 首批模板:请假、补卡、外出、加班。
|
||||||
- 每个模板需记录:模板 ID、适用范围、字段项、审批人规则、可见范围、所属自建应用权限。
|
- 每个模板需记录:模板 ID、适用范围、字段项、审批人规则、可见范围、所属自建应用权限。
|
||||||
- 测试审批单 `sp_no`:必须为低风险样例,不含采购、合同、报销、发票、银行回单、高敏附件或真实敏感业务资料。
|
- 测试审批单 `sp_no`:必须为低风险样例,不含采购、合同、报销、发票、银行回单、高敏附件或真实敏感业务资料。
|
||||||
- 回调配置:公网 Callback URL、Token、EncodingAESKey 只进入部署环境;本地文档只记录变量名和配置状态。
|
- 回调配置:公网 Callback URL、Token、EncodingAESKey 只进入部署环境;本地文档只记录 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY` 变量名和配置状态。
|
||||||
- 后续 POC:模板详情读取、审批详情读取、回调验签/解密、事件幂等、重试语义和补偿拉取方案。
|
- 后续 POC:模板详情读取、审批详情读取、回调验签/解密、事件幂等、重试语义和补偿拉取方案。
|
||||||
|
|
||||||
## 5. WAPC2 请假/补卡只读 POC
|
## 5. WAPC2 请假/补卡只读 POC
|
||||||
@@ -102,6 +102,7 @@ WPROC1 将 WAPC2 之后的审批路线固定为:
|
|||||||
- `WAPC2F` 固定 `WECOM_AGENT_SECRET`,不得 fallback 到通讯录、打卡或历史 HR assistant Secret。
|
- `WAPC2F` 固定 `WECOM_AGENT_SECRET`,不得 fallback 到通讯录、打卡或历史 HR assistant Secret。
|
||||||
- `WAPC2F` 默认申请人 `SMOKE002`、审批人 `SMOKE101`;命中自审、同一企微 userid、不可见、未绑定或 inactive 时硬阻断。
|
- `WAPC2F` 默认申请人 `SMOKE002`、审批人 `SMOKE101`;命中自审、同一企微 userid、不可见、未绑定或 inactive 时硬阻断。
|
||||||
- `WCBK1` 必须先证明真实回调 URL 验证、验签、解密、幂等键和重复投递,不保存 Token、EncodingAESKey 或原始密文。
|
- `WCBK1` 必须先证明真实回调 URL 验证、验签、解密、幂等键和重复投递,不保存 Token、EncodingAESKey 或原始密文。
|
||||||
|
- `WCBK1` 当前已完成代码部署和 synthetic dry-run:`npm run check:wecom-approval-callback-poc` 远端报告为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,synthetic 回调加密链路为 OK。真实 URL 验证需要管理员在部署环境配置 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,并在企微后台配置回调;真实事件确认需要 `SMOKE101` 处理一条现有 OA-POC 测试审批单。
|
||||||
- `WACP3` 必须先证明按时间窗口批量获取审批单号并可调用 `getapprovaldetail` 拉详情;缺权限、可信 IP 或可见范围时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
|
- `WACP3` 必须先证明按时间窗口批量获取审批单号并可调用 `getapprovaldetail` 拉详情;缺权限、可信 IP 或可见范围时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
|
||||||
- `WADM1` 必须先设计镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。
|
- `WADM1` 必须先设计镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
**文档状态**:当前开发路线图事实源。
|
**文档状态**:当前开发路线图事实源。
|
||||||
**分类**:开发治理 / 路线图。
|
**分类**:开发治理 / 路线图。
|
||||||
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE 之后。
|
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 之后。
|
||||||
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE`。
|
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1`。
|
||||||
**最近审计**:2026-05-19。
|
**最近审计**:2026-05-19。
|
||||||
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`;这只放行下一步回调只读 POC,不代表真实回调、补偿拉取、镜像数据模型或 `wecom_attendance_approval_mirror_v1` 已完成。
|
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。WHMSLIVE 后只代表企微 HR 主数据镜像已部署并受控验证;WAPI 后进一步固定企微 API/Secret 口径并转为运行优先;WRRA 后明确 `PASS_WITH_FINDINGS` 只代表只读对账成功且存在差异,不代表企微考勤/审批镜像、OA 应急接管、恢复回写或 RuleOps 已完成运行时实现。WMSG1 只证明企业微信应用消息可达和 Deeplink 可回到 OA;WAPC1 只固化审批配置准备清单;WAPC2 只新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC,不代表真实回调或镜像能力已完成。WPROC1 后,WAPC2 不直接放行深度镜像,必须先完成自动样件、回调、补偿拉取和镜像数据模型设计门禁。WPROC2 只新增可执行 process gate。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,并自动复跑 WAPC2 得到 `PASS_APPROVAL_POC`。WCBK1 已部署只读回调端点和 synthetic runner,公网报告为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,只证明代码链路可验签/解密/解析,不代表真实回调 URL 已在企微后台验证,也不代表补偿拉取、镜像数据模型或 `wecom_attendance_approval_mirror_v1` 已完成。
|
||||||
|
|
||||||
## 1. 路线图原则
|
## 1. 路线图原则
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
| `WPROC2` | 流程自动化门禁已新增 | 新增 `scripts/qa/check-process-contract.mjs`,把 WPROC1 的三层门禁、DoR/DoD、自动化优先、非自审、Secret 固定、live 写入开关、缺配置分类和脱敏边界升级为可执行 process gate;WAPC2F 实施前必须先通过 baseline gate 和 `wecom-approval-fixture` profile。 |
|
| `WPROC2` | 流程自动化门禁已新增 | 新增 `scripts/qa/check-process-contract.mjs`,把 WPROC1 的三层门禁、DoR/DoD、自动化优先、非自审、Secret 固定、live 写入开关、缺配置分类和脱敏边界升级为可执行 process gate;WAPC2F 实施前必须先通过 baseline gate 和 `wecom-approval-fixture` profile。 |
|
||||||
| `WAPC2F` | 已实现并公网 dry-run 通过 | 新增 QA-only `npm run check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET` 构建 OA-POC 请假/补卡最小通用模板和低风险审批申请 payload;默认 dry-run 不获取写入 token、不调用企微写接口。公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`:`SMOKE002 -> SMOKE101` active、已绑定、非自审。 |
|
| `WAPC2F` | 已实现并公网 dry-run 通过 | 新增 QA-only `npm run check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET` 构建 OA-POC 请假/补卡最小通用模板和低风险审批申请 payload;默认 dry-run 不获取写入 token、不调用企微写接口。公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`:`SMOKE002 -> SMOKE101` active、已绑定、非自审。 |
|
||||||
| `WAPC2FLIVE` | 已完成 live 创建与 WAPC2 自动复跑 | 用户批准后执行一次 live 创建窗口:第一次旧 payload 返回 `301086` 且未创建任何模板或审批单;修正为官方 `template_name` + `property` controls 后,远端报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,`checks.autoRerun.status=OK` 且 `PASS_APPROVAL_POC`。测试模板和测试审批记录保留审计,不自动删除;仍不放行深度镜像。 |
|
| `WAPC2FLIVE` | 已完成 live 创建与 WAPC2 自动复跑 | 用户批准后执行一次 live 创建窗口:第一次旧 payload 返回 `301086` 且未创建任何模板或审批单;修正为官方 `template_name` + `property` controls 后,远端报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,`checks.autoRerun.status=OK` 且 `PASS_APPROVAL_POC`。测试模板和测试审批记录保留审计,不自动删除;仍不放行深度镜像。 |
|
||||||
|
| `WCBK1` | 已部署代码并完成公网 dry-run;待人工配置真实回调 | 新增只读 `GET/POST /api/wecom/approval/callback`、`npm run check:wecom-approval-callback-poc` 和 `wecom-callback` process gate。远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` 为 `PARTIAL_CALLBACK_NEEDS_CONFIG`:`synthetic_callback_crypto=OK`,但缺 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,尚未证明企微后台真实 URL 验证或真实审批事件回调。 |
|
||||||
|
|
||||||
## 3. 阶段路线
|
## 3. 阶段路线
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
| 4 | `WAPC1 / WAPC2` | 审批配置准备和请假/补卡模板详情、低风险审批详情只读 POC。 | 运行优先门禁完成;WMSG1 消息和 Deeplink 已确认。 | WAPC2 runner 已部署,缺模板 ID 或 `sp_no` 时真实记录 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,不放行深度镜像。 |
|
| 4 | `WAPC1 / WAPC2` | 审批配置准备和请假/补卡模板详情、低风险审批详情只读 POC。 | 运行优先门禁完成;WMSG1 消息和 Deeplink 已确认。 | WAPC2 runner 已部署,缺模板 ID 或 `sp_no` 时真实记录 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`,不放行深度镜像。 |
|
||||||
| 5 | `WAPC2F 自动生成 POC 样件` | 尽量用 QA-only runner 自动创建 OA-POC 请假/补卡测试模板和低风险测试审批单。 | WAPC2 已证明 Secret 可用但缺模板或 `sp_no`;WPROC2 baseline gate 和 WAPC2F profile gate 通过;dry-run 先通过;live 写入需用户批准。 | 已完成:`PASS_FIXTURE_CREATED`。创建使用显式 `--live --create-templates --submit-test-approvals` 和单次进程 `WECOM_APPROVAL_FIXTURE_WRITE=1`;申请人 `SMOKE002`、审批人 `SMOKE101` 通过 active、绑定、可见和非自审预检;报告只记录脱敏模板 ID / `sp_no` 前缀。 |
|
| 5 | `WAPC2F 自动生成 POC 样件` | 尽量用 QA-only runner 自动创建 OA-POC 请假/补卡测试模板和低风险测试审批单。 | WAPC2 已证明 Secret 可用但缺模板或 `sp_no`;WPROC2 baseline gate 和 WAPC2F profile gate 通过;dry-run 先通过;live 写入需用户批准。 | 已完成:`PASS_FIXTURE_CREATED`。创建使用显式 `--live --create-templates --submit-test-approvals` 和单次进程 `WECOM_APPROVAL_FIXTURE_WRITE=1`;申请人 `SMOKE002`、审批人 `SMOKE101` 通过 active、绑定、可见和非自审预检;报告只记录脱敏模板 ID / `sp_no` 前缀。 |
|
||||||
| 6 | `WAPC2 自动复跑` | 使用 WAPC2F 生成的脱敏模板 ID 和 `sp_no` 自动复跑只读 POC。 | WAPC2F live 写入生成全部或部分样件。 | 已完成:live 报告中 `checks.autoRerun.status=OK`,请假/补卡模板详情与审批详情可读,结论 `PASS_APPROVAL_POC`。 |
|
| 6 | `WAPC2 自动复跑` | 使用 WAPC2F 生成的脱敏模板 ID 和 `sp_no` 自动复跑只读 POC。 | WAPC2F live 写入生成全部或部分样件。 | 已完成:live 报告中 `checks.autoRerun.status=OK`,请假/补卡模板详情与审批详情可读,结论 `PASS_APPROVAL_POC`。 |
|
||||||
| 7 | `WCBK1 回调只读 POC` | 证明真实回调 URL 验证、验签、解密、幂等键和重复投递处理。 | 回调 URL、Token、EncodingAESKey 由部署环境配置;不得写入仓库或聊天。 | 真实回调可达、可验签/解密、重复投递幂等、失败重试语义明确;缺配置记录 `NEEDS_CONFIG`。 |
|
| 7 | `WCBK1 回调只读 POC` | 证明真实回调 URL 验证、验签、解密、幂等键和重复投递处理。 | 回调 URL、Token、EncodingAESKey 由部署环境配置;不得写入仓库或聊天。 | 当前已部署只读端点和 synthetic runner,结论 `PARTIAL_CALLBACK_NEEDS_CONFIG`;退出需配置真实回调并达到 `PASS_CALLBACK_URL_VERIFIED`,再由 `SMOKE101` 处理现有 OA-POC 测试单触发事件并达到 `PASS_CALLBACK_EVENT_CONFIRMED`。 |
|
||||||
| 8 | `WACP3 补偿拉取 POC` | 证明按时间窗口批量获取审批单号并可拉取审批详情,作为回调丢失补偿。 | WAPC2 或 WAPC2F 至少能提供低风险样例;官方 API 和权限明确。 | 能按窗口拿到审批单号并拉详情;不能伪造成 PASS,权限或可信 IP 问题记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 |
|
| 8 | `WACP3 补偿拉取 POC` | 证明按时间窗口批量获取审批单号并可拉取审批详情,作为回调丢失补偿。 | WAPC2 或 WAPC2F 至少能提供低风险样例;官方 API 和权限明确。 | 能按窗口拿到审批单号并拉详情;不能伪造成 PASS,权限或可信 IP 问题记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 |
|
||||||
| 9 | `WADM1 镜像数据模型设计` | 先设计镜像字段、状态机、幂等键、索引、隐私保留和回滚方案。 | WAPC2/WAPC2F、WCBK1、WACP3 结论可支撑模型设计。 | 数据模型、状态流、幂等、索引、脱敏、审计和迁移回滚方案经 Issue CSV 固化后,才允许实现镜像。 |
|
| 9 | `WADM1 镜像数据模型设计` | 先设计镜像字段、状态机、幂等键、索引、隐私保留和回滚方案。 | WAPC2/WAPC2F、WCBK1、WACP3 结论可支撑模型设计。 | 数据模型、状态流、幂等、索引、脱敏、审计和迁移回滚方案经 Issue CSV 固化后,才允许实现镜像。 |
|
||||||
| 10 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | 运行优先门禁完成且 WOUA 差异已收口或有明确处理决策;WAPC2FLIVE/WCBK1/WACP3/WADM1 均完成;外出/加班模板和打卡规则细项明确。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
|
| 10 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | 运行优先门禁完成且 WOUA 差异已收口或有明确处理决策;WAPC2FLIVE/WCBK1/WACP3/WADM1 均完成;外出/加班模板和打卡规则细项明确。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ WDRG 后,企微优先 HR/考勤改造增加一层路线图门禁;WAPI/WRRA
|
|||||||
|
|
||||||
`需求事实源 -> 设计门禁 -> 路线图门禁 -> 企微能力 POC -> 单功能 Issue CSV -> 方案批准 -> 开发实现 -> review -> QA 回归 -> 部署 / 公网验证`
|
`需求事实源 -> 设计门禁 -> 路线图门禁 -> 企微能力 POC -> 单功能 Issue CSV -> 方案批准 -> 开发实现 -> review -> QA 回归 -> 部署 / 公网验证`
|
||||||
|
|
||||||
路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 的剩余缺字段/归档绑定不直接放行深度镜像;WRA1 已完成 `WECOM-RUNTIME-READY-V1` 的运行入口、OAuth/绑定、脱敏消息、通讯录、人事助手和打卡只读门禁修正;WMSG1 已完成真实应用消息和 Deeplink 确认。当前 `PASS_WITH_CONFIG_GAPS` 只表示运行优先主链路可用,WAPC1 只表示审批配置准备清单已固化;WAPC2 只证明请假/补卡模板详情和审批详情只读 runner 已具备。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试样件并自动复跑 WAPC2 通过;审批镜像前仍必须先完成 WCBK1 回调只读 POC、WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计。
|
路线图只控制顺序、边界、准入和退出条件;具体接口、schema、页面、权限、审计和测试仍必须在单功能 Issue CSV 中声明。阶段未退出前不得跳阶段:WOUA-FIX 已完成测试账号差异收口,但 `PASS_WITH_FINDINGS` 的剩余缺字段/归档绑定不直接放行深度镜像;WRA1 已完成 `WECOM-RUNTIME-READY-V1` 的运行入口、OAuth/绑定、脱敏消息、通讯录、人事助手和打卡只读门禁修正;WMSG1 已完成真实应用消息和 Deeplink 确认。当前 `PASS_WITH_CONFIG_GAPS` 只表示运行优先主链路可用,WAPC1 只表示审批配置准备清单已固化;WAPC2 只证明请假/补卡模板详情和审批详情只读 runner 已具备。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试样件并自动复跑 WAPC2 通过;WCBK1 已部署回调只读代码和 synthetic 验签/解密/解析 runner,但真实回调 URL/Token/EncodingAESKey 未配置,结论仍为 `PARTIAL_CALLBACK_NEEDS_CONFIG`。审批镜像前仍必须先完成 WCBK1 真实 URL/事件确认、WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计。
|
||||||
|
|
||||||
## 2. Issue 生命周期
|
## 2. Issue 生命周期
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ WDRG 后,企微优先 HR/考勤路线采用“路线图门禁 + 单功能 Issu
|
|||||||
|
|
||||||
当前企微优先路线顺序固定为:
|
当前企微优先路线顺序固定为:
|
||||||
|
|
||||||
`WECOM-CAPABILITY-POC-V1 -> wecom_hr_master_sync_v1 -> runtime_first_wecom_enablement -> WOUA-FIX / WECOM-RUNTIME-READY-V1 -> WAPC1 审批配置准备 -> WAPC2 请假/补卡只读 POC -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WACP3 补偿拉取 POC -> WADM1 镜像数据模型设计 -> wecom_attendance_approval_mirror_v1 -> oa_hr_emergency_mode_v1 -> wecom_recovery_writeback_v1 -> wecom_ruleops_v1`
|
`WECOM-CAPABILITY-POC-V1 -> wecom_hr_master_sync_v1 -> runtime_first_wecom_enablement -> WOUA-FIX / WECOM-RUNTIME-READY-V1 -> WAPC1 审批配置准备 -> WAPC2 请假/补卡只读 POC -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC(代码/synthetic 已部署,真实配置待补) -> WACP3 补偿拉取 POC -> WADM1 镜像数据模型设计 -> wecom_attendance_approval_mirror_v1 -> oa_hr_emergency_mode_v1 -> wecom_recovery_writeback_v1 -> wecom_ruleops_v1`
|
||||||
|
|
||||||
允许并行处理线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护;但采购、合同、报销、付款、AP、代账和高敏附件等 OA 主办域必须另开合同,不得混入企微 HR/考勤路线。
|
允许并行处理线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护;但采购、合同、报销、付款、AP、代账和高敏附件等 OA 主办域必须另开合同,不得混入企微 HR/考勤路线。
|
||||||
|
|
||||||
|
|||||||
+16
-15
文件差异因一行或多行过长而隐藏
+5
-3
@@ -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。
|
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。
|
||||||
|
|
||||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||||
@@ -75,6 +75,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
- WPROC2 是 WPROC1 后的开发流程自动化门禁合同:新增 `scripts/qa/check-process-contract.mjs` process gate,并接入 `scripts/check-minimal.sh`;WAPC2F 实施前必须先通过 baseline gate 和 `wecom-approval-fixture` profile。本轮不部署、不写 OA/企微数据、不实现 WAPC2F 业务 runner。
|
- WPROC2 是 WPROC1 后的开发流程自动化门禁合同:新增 `scripts/qa/check-process-contract.mjs` process gate,并接入 `scripts/check-minimal.sh`;WAPC2F 实施前必须先通过 baseline gate 和 `wecom-approval-fixture` profile。本轮不部署、不写 OA/企微数据、不实现 WAPC2F 业务 runner。
|
||||||
- WAPC2F 是 WPROC2 后的企微审批 POC 样件自动生成 dry-run 合同:新增 `server/src/scripts/check-wecom-approval-fixtures.js` 和 `npm run check:wecom-approval-fixtures`,公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`。
|
- WAPC2F 是 WPROC2 后的企微审批 POC 样件自动生成 dry-run 合同:新增 `server/src/scripts/check-wecom-approval-fixtures.js` 和 `npm run check:wecom-approval-fixtures`,公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T045001Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`。
|
||||||
- WAPC2FLIVE 是 WAPC2F 后的企微审批 OA-POC 样件 live 创建合同:用户批准后执行 live 窗口,修正 `create_template` payload 为官方 `template_name` + `property` controls 结构;远端报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,请假/补卡测试模板和低风险测试审批单已创建并保留审计,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;不代表真实回调、补偿拉取、镜像模型或深度镜像已完成。
|
- WAPC2FLIVE 是 WAPC2F 后的企微审批 OA-POC 样件 live 创建合同:用户批准后执行 live 窗口,修正 `create_template` payload 为官方 `template_name` + `property` controls 结构;远端报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,请假/补卡测试模板和低风险测试审批单已创建并保留审计,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;不代表真实回调、补偿拉取、镜像模型或深度镜像已完成。
|
||||||
|
- WCBK1 是 WAPC2FLIVE 后的企微审批回调只读 POC 合同:已新增只读回调端点、验签/解密/归一化服务、QA runner 和 `wecom-callback` process gate;远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` 为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,synthetic 回调链路 OK,但真实回调 URL/Token/EncodingAESKey 尚未配置,不代表真实事件回调或镜像已完成。
|
||||||
|
|
||||||
## 当前活跃治理流
|
## 当前活跃治理流
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
| `WPROC2` | `issues/2026-05-19_11-15-10-process-gate-automation-v1.csv` | 当前流程自动化门禁:process gate 框架 | 新增 `check-process-contract` QA-only CLI、WAPC2F profile、最小门禁接入和事实源回写;WAPC2F 实施前先跑 baseline gate | 不部署、不改业务 API/schema/UI、不写 OA/企微数据、不实现 WAPC2F runner |
|
| `WPROC2` | `issues/2026-05-19_11-15-10-process-gate-automation-v1.csv` | 当前流程自动化门禁:process gate 框架 | 新增 `check-process-contract` QA-only CLI、WAPC2F profile、最小门禁接入和事实源回写;WAPC2F 实施前先跑 baseline gate | 不部署、不改业务 API/schema/UI、不写 OA/企微数据、不实现 WAPC2F runner |
|
||||||
| `WAPC2F` | `issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv` | 已完成:企微审批 POC 样件自动生成 runner 已部署并公网 dry-run 通过 | 新增 `check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET`,默认 dry-run 构建 OA-POC 请假/补卡模板和审批申请 payload;`SMOKE002 -> SMOKE101` 非自审预检通过;远端报告 `PASS_FIXTURE_DRY_RUN_READY` | 不新增公开 API/schema/UI;dry-run 不真实创建模板或审批单;不接回调;不进入深度镜像 |
|
| `WAPC2F` | `issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv` | 已完成:企微审批 POC 样件自动生成 runner 已部署并公网 dry-run 通过 | 新增 `check:wecom-approval-fixtures`,固定 `WECOM_AGENT_SECRET`,默认 dry-run 构建 OA-POC 请假/补卡模板和审批申请 payload;`SMOKE002 -> SMOKE101` 非自审预检通过;远端报告 `PASS_FIXTURE_DRY_RUN_READY` | 不新增公开 API/schema/UI;dry-run 不真实创建模板或审批单;不接回调;不进入深度镜像 |
|
||||||
| `WAPC2FLIVE` | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv` | 当前审批样件 live:OA-POC 请假/补卡测试模板和测试审批单已创建 | 修正 `create_template` payload 后,远端报告 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,只记录脱敏 template_id / `sp_no` 前缀 | 不改公开 API/schema/UI;不接真实回调;不做补偿拉取;不建镜像表;不进入深度镜像 |
|
| `WAPC2FLIVE` | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv` | 当前审批样件 live:OA-POC 请假/补卡测试模板和测试审批单已创建 | 修正 `create_template` payload 后,远端报告 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,只记录脱敏 template_id / `sp_no` 前缀 | 不改公开 API/schema/UI;不接真实回调;不做补偿拉取;不建镜像表;不进入深度镜像 |
|
||||||
|
| `WCBK1` | `issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv` | 当前回调只读 POC:代码已部署,真实配置待补 | 新增只读 `GET/POST /api/wecom/approval/callback`、`check:wecom-approval-callback-poc` 和 `wecom-callback` process gate;远端 synthetic 验签/解密/解析 OK,报告 `PARTIAL_CALLBACK_NEEDS_CONFIG` | 不改 schema/UI;不建镜像表;不写 OA/企微业务数据;不创建新模板或审批单;真实 URL 验证和事件确认需管理员配置回调后再复跑 |
|
||||||
|
|
||||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||||
|
|
||||||
@@ -159,8 +161,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
|||||||
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC2FLIVE 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例和深度镜像前置口径 |
|
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WAPC2FLIVE 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例和深度镜像前置口径 |
|
||||||
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
|
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
|
||||||
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||||
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、回调、补偿拉取和模型设计事实源 |
|
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、回调配置缺口、补偿拉取和模型设计事实源 |
|
||||||
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建和 WCBK1/WACP3/WADM1 决策事实源 |
|
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调 POC 和 WACP3/WADM1 决策事实源 |
|
||||||
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WAPC2FLIVE 后包含审批配置、只读 POC、工程化前置阶段顺序、process gate 和请假/补卡样例口径 |
|
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WAPC2FLIVE 后包含审批配置、只读 POC、工程化前置阶段顺序、process gate 和请假/补卡样例口径 |
|
||||||
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 |
|
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 |
|
||||||
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
|
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**文档状态**:当前测试事实源。
|
**文档状态**:当前测试事实源。
|
||||||
**适用范围**:hyb-oa MVP 首轮开发 A3-A8,以及后续所有非微小改动。
|
**适用范围**:hyb-oa MVP 首轮开发 A3-A8,以及后续所有非微小改动。
|
||||||
**路线口径**:WFOC/DBWFOC/WFDG/WDRG 后,HR/考勤员工日常审批优先复用企业微信成熟能力,OA 做按域镜像、人工接管、恢复回写、财务闭环和审计中台;报销、采购、合同、项目、PDF OCR、付款批次、AP 台账、代账交接和高敏附件继续由 OA 主办;企业微信只做脱敏消息和 OA deeplink;飞书不进入当前路线。后续企微/OA 运行时迁移前必须先读 [企微优先开发路线图](./企微优先开发路线图.md),再做对应 POC。WPROC1 后,WAPC2 不能直接进入 `wecom_attendance_approval_mirror_v1`,必须先完成 WAPC2F、WCBK1、WACP3 和 WADM1;WPROC2 后先跑 `check-process-contract` process gate;缺配置或权限时记录 `NEEDS_CONFIG` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,不得伪造成通过。
|
**路线口径**:WFOC/DBWFOC/WFDG/WDRG 后,HR/考勤员工日常审批优先复用企业微信成熟能力,OA 做按域镜像、人工接管、恢复回写、财务闭环和审计中台;报销、采购、合同、项目、PDF OCR、付款批次、AP 台账、代账交接和高敏附件继续由 OA 主办;企业微信只做脱敏消息和 OA deeplink;飞书不进入当前路线。后续企微/OA 运行时迁移前必须先读 [企微优先开发路线图](./企微优先开发路线图.md),再做对应 POC。WPROC1 后,WAPC2 不能直接进入 `wecom_attendance_approval_mirror_v1`,必须先完成 WAPC2F、WCBK1、WACP3 和 WADM1;WCBK1 当前代码和 synthetic runner 已部署,但真实 URL/事件确认仍待配置;WPROC2 后先跑 `check-process-contract` process gate;缺配置或权限时记录 `NEEDS_CONFIG` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,不得伪造成通过。
|
||||||
|
|
||||||
本文回答“什么时候测试、测什么、怎么判定通过、怎么记录跳过”。具体业务验收仍以主 PRD、模块规约和最新 Issue CSV 为准。
|
本文回答“什么时候测试、测什么、怎么判定通过、怎么记录跳过”。具体业务验收仍以主 PRD、模块规约和最新 Issue CSV 为准。
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ Definition of Done:
|
|||||||
- 人工只保留批准、密钥/回调配置、企微后台不可 API 化配置、真机确认和风险拍板。
|
- 人工只保留批准、密钥/回调配置、企微后台不可 API 化配置、真机确认和风险拍板。
|
||||||
- 若面向公网或企微可测,必须有 `/ready`、runtime smoke、public bundle scan 或对应 live runner 证据。
|
- 若面向公网或企微可测,必须有 `/ready`、runtime smoke、public bundle scan 或对应 live runner 证据。
|
||||||
|
|
||||||
高风险企微能力的 POC 顺序固定为:WAPC2F 自动样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WACP3 补偿拉取 POC -> WADM1 镜像数据模型设计 -> `wecom_attendance_approval_mirror_v1`。当前 WAPC2FLIVE 已完成,后续不得跳过回调、补偿和模型门禁。真实回调必须先证明验签、解密、幂等和重复投递;补偿拉取必须先证明按时间窗口拿到审批单号并可拉详情;镜像实现前必须先有字段、状态机、幂等键、索引和隐私保留边界设计。
|
高风险企微能力的 POC 顺序固定为:WAPC2F 自动样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WACP3 补偿拉取 POC -> WADM1 镜像数据模型设计 -> `wecom_attendance_approval_mirror_v1`。当前 WAPC2FLIVE 已完成,WCBK1 已完成代码部署和公网 synthetic dry-run,但真实回调 URL 验证和事件确认仍待管理员配置后复跑;后续不得跳过真实回调确认、补偿和模型门禁。真实回调必须先证明验签、解密、幂等和重复投递;补偿拉取必须先证明按时间窗口拿到审批单号并可拉详情;镜像实现前必须先有字段、状态机、幂等键、索引和隐私保留边界设计。
|
||||||
|
|
||||||
## 4. MVP 首轮测试矩阵
|
## 4. MVP 首轮测试矩阵
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@
|
|||||||
| 恢复回写层 | OA + 企业微信 API | 企微恢复后由 `wecom_recovery_writeback_v1` 对应急数据执行 dry-run、自动回写、失败重试和冲突清单。 |
|
| 恢复回写层 | OA + 企业微信 API | 企微恢复后由 `wecom_recovery_writeback_v1` 对应急数据执行 dry-run、自动回写、失败重试和冲突清单。 |
|
||||||
| 财务主办层 | OA | 报销、PDF 发票 OCR、发票查重、付款批次、银行回单、AP 台账、代账交接继续 OA 主办,不迁移到企微。 |
|
| 财务主办层 | OA | 报销、PDF 发票 OCR、发票查重、付款批次、银行回单、AP 台账、代账交接继续 OA 主办,不迁移到企微。 |
|
||||||
|
|
||||||
无痛切换是长期目标,不代表本轮已实现完整替代。完整镜像按域定义:企微主办域镜像到 OA;OA 主办域不反向全量镜像到企微,只发送脱敏标题、业务类型、业务编号、状态和 OA 链接。后续必须按 `wecom_hr_master_sync_v1`、`runtime_first_wecom_enablement`、`WOUA-FIX / WECOM-RUNTIME-READY-V1`、`WAPC1 审批配置准备`、`WAPC2 请假/补卡只读 POC`、`WAPC2F 自动生成 POC 样件`、`WAPC2 自动复跑`、`WCBK1 回调只读 POC`、`WACP3 补偿拉取 POC`、`WADM1 镜像数据模型设计`、`wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 和 `wecom_ruleops_v1` 分阶段拆 Issue CSV。
|
无痛切换是长期目标,不代表本轮已实现完整替代。完整镜像按域定义:企微主办域镜像到 OA;OA 主办域不反向全量镜像到企微,只发送脱敏标题、业务类型、业务编号、状态和 OA 链接。后续必须按 `wecom_hr_master_sync_v1`、`runtime_first_wecom_enablement`、`WOUA-FIX / WECOM-RUNTIME-READY-V1`、`WAPC1 审批配置准备`、`WAPC2 请假/补卡只读 POC`、`WAPC2F 自动生成 POC 样件`、`WAPC2 自动复跑`、`WCBK1 回调只读 POC(代码/synthetic 已部署,真实配置待补)`、`WACP3 补偿拉取 POC`、`WADM1 镜像数据模型设计`、`wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 和 `wecom_ruleops_v1` 分阶段拆 Issue CSV。
|
||||||
|
|
||||||
### 1.5 DBWFOC 数据边界修正
|
### 1.5 DBWFOC 数据边界修正
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ WFDG 将 DBWFOC 后的产品方向固化为开发前设计门禁。本节只代
|
|||||||
| 报销、采购、合同、项目、付款、AP、代账 | OA | 主办提交、审批、附件、导出、付款批次、审计和代账资料 | 只发送脱敏消息和 OA deeplink | 采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不进入企微原生审批或企微附件 |
|
| 报销、采购、合同、项目、付款、AP、代账 | OA | 主办提交、审批、附件、导出、付款批次、审计和代账资料 | 只发送脱敏消息和 OA deeplink | 采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不进入企微原生审批或企微附件 |
|
||||||
| RuleOps | OA + 企业微信 API | AI 草拟、差异报告、dry-run、人工确认、审计和回滚建议 | 接收经确认的审批模板、打卡规则、排班、假期余额或人事字段配置写入 | AI 不得直接改正式规则;每次写入保存前后快照、确认人、API `errcode` 和失败原因 |
|
| RuleOps | OA + 企业微信 API | AI 草拟、差异报告、dry-run、人工确认、审计和回滚建议 | 接收经确认的审批模板、打卡规则、排班、假期余额或人事字段配置写入 | AI 不得直接改正式规则;每次写入保存前后快照、确认人、API `errcode` 和失败原因 |
|
||||||
|
|
||||||
`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像;WOUA-FIX 已将 7 个缺企微测试账号软归档并复检 `oaActiveMissingInWecom=0`,企微缺字段先接受不补;WMSG1 已证明真实企微应用消息和 Deeplink 可用;WAPC1 已固化审批配置准备清单;WAPC2 已提供请假/补卡只读 POC runner;WPROC1 已固定自动化优先、非自审和高风险 POC 前置。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WACP3 补偿拉取 POC` -> `WADM1 镜像数据模型设计` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像;WOUA-FIX 已将 7 个缺企微测试账号软归档并复检 `oaActiveMissingInWecom=0`,企微缺字段先接受不补;WMSG1 已证明真实企微应用消息和 Deeplink 可用;WAPC1 已固化审批配置准备清单;WAPC2 已提供请假/补卡只读 POC runner;WAPC2FLIVE 已创建 OA-POC 样件并自动复跑通过;WCBK1 已部署回调只读代码和 synthetic runner,但真实配置仍缺;WPROC1 已固定自动化优先、非自审和高风险 POC 前置。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2 自动复跑` -> `WCBK1 回调只读 POC(真实配置待补)` -> `WACP3 补偿拉取 POC` -> `WADM1 镜像数据模型设计` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||||
|
|
||||||
### 1.7 WDRG 开发路线图门禁
|
### 1.7 WDRG 开发路线图门禁
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
# hyb-oa 管理员手册(10 人以下团队版)
|
# hyb-oa 管理员手册(10 人以下团队版)
|
||||||
|
|
||||||
**当前状态(2026-05-19 / WAPC2FLIVE)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run 和 WAPC2FLIVE 均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WPROC1 明确下一步仍需 WCBK1 回调只读 POC、WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计,不能直接进入 `wecom_attendance_approval_mirror_v1`。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
**当前状态(2026-05-19 / WCBK1)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE 和 WCBK1 代码部署均已收口;SYSTEM-COMPLETE-GATE 结论为 `GO_TO_USE`。WAPI 后固定企微 API 能力目录和 Secret 矩阵:通讯录使用 `WECOM_CONTACT_SECRET`,人事助手/打卡/消息/OAuth/审批读取使用 `WECOM_AGENT_SECRET`,运行时代码不得靠多个 Secret 试错。WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` 为 `PASS_FIXTURE_CREATED`,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`;测试模板和审批记录保留审计,不自动删除。WCBK1 已部署只读回调端点和 synthetic runner,远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json` 为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,仍需管理员配置 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY` 后做企微后台 URL 验证和真实事件确认。WPROC1 明确后续仍需 WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计,不能直接进入 `wecom_attendance_approval_mirror_v1`。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
||||||
|
|
||||||
## 1. 适用范围
|
## 1. 适用范围
|
||||||
- 适用于 `small_team_internal` 工作区模式。
|
- 适用于 `small_team_internal` 工作区模式。
|
||||||
@@ -237,6 +237,14 @@ WAPC1 审批配置准备清单:
|
|||||||
5. 后续进入 POC 前必须确认:模板详情读取、审批详情读取、回调 URL 验证、事件验签、解密、幂等键、重试语义和补偿拉取方案。
|
5. 后续进入 POC 前必须确认:模板详情读取、审批详情读取、回调 URL 验证、事件验签、解密、幂等键、重试语义和补偿拉取方案。
|
||||||
6. 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。
|
6. 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。
|
||||||
|
|
||||||
|
WCBK1 回调只读 POC:
|
||||||
|
|
||||||
|
1. 当前公网 Callback URL 应配置为 `https://oa.hyinda.com/api/wecom/approval/callback`。
|
||||||
|
2. 部署环境变量只写名称,不在文档或聊天保存真实值:`WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`。
|
||||||
|
3. `npm run check:wecom-approval-callback-poc` 当前远端结论为 `PARTIAL_CALLBACK_NEEDS_CONFIG`,synthetic 验签/解密/解析已通过。
|
||||||
|
4. 配置完成后先复跑 live runner,预期 `PASS_CALLBACK_URL_VERIFIED`;随后由 `SMOKE101` 在企业微信处理一条现有 OA-POC 测试审批单,触发真实事件后复跑,预期 `PASS_CALLBACK_EVENT_CONFIRMED`。
|
||||||
|
5. 报告只保存事件类型、审批类型、状态摘要、脱敏 `sp_no`/模板前缀和幂等键摘要;不得截图、转存或粘贴 Token、EncodingAESKey、原始 XML、密文、完整 `sp_no`、完整 userid、表单值、附件或评论。
|
||||||
|
|
||||||
WAPC2 请假/补卡只读 POC 操作口径:
|
WAPC2 请假/补卡只读 POC 操作口径:
|
||||||
|
|
||||||
1. 优先用 WAPC2F runner 自动生成 OA-POC 请假/补卡测试模板和低风险测试审批单;当前 dry-run 已通过,真实创建模板/审批单前仍需管理员批准单次 live 写入。
|
1. 优先用 WAPC2F runner 自动生成 OA-POC 请假/补卡测试模板和低风险测试审批单;当前 dry-run 已通过,真实创建模板/审批单前仍需管理员批准单次 live 写入。
|
||||||
|
|||||||
+4
-4
@@ -6,7 +6,7 @@
|
|||||||
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
|
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
|
||||||
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[企微 API 能力目录](./企微API能力目录.md)
|
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[企微 API 能力目录](./企微API能力目录.md)
|
||||||
|
|
||||||
本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后审批进入配置准备清单,WAPC2 后先做请假/补卡模板详情与低风险 `sp_no` 只读 POC;WAPC2F 已实现 OA-POC 样件 dry-run 并公网验证 `PASS_FIXTURE_DRY_RUN_READY`。WAPC2FLIVE 已在用户批准后创建 OA-POC 请假/补卡测试模板和低风险测试审批单,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。WPROC1 后仍必须先做回调、补偿拉取和镜像数据模型门禁,不是直接进入审批镜像实现。
|
本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 镜像、人工接管和恢复回写要求。WFOC 起产品方向调整为“企微优先 + OA 无痛接管”;DBWFOC 后进一步固定为:HR/考勤员工日常审批优先使用企业微信,OA 做按域镜像、备份接管、财务闭环和审计中台;采购、合同、项目、财务和高敏附件继续由 OA 主办。WAPI 后,所有企微相关开发必须先读取本规约和 [企微 API 能力目录](./企微API能力目录.md),确认官方 API、Secret、权限、可信 IP、回调/模板和读写边界,再拆 Issue CSV。WMSG1 已证明企业微信应用消息和 Deeplink 真机回流可用;WAPC1 后审批进入配置准备清单,WAPC2 后先做请假/补卡模板详情与低风险 `sp_no` 只读 POC;WAPC2F 已实现 OA-POC 样件 dry-run 并公网验证 `PASS_FIXTURE_DRY_RUN_READY`。WAPC2FLIVE 已在用户批准后创建 OA-POC 请假/补卡测试模板和低风险测试审批单,自动复跑 WAPC2 为 `PASS_APPROVAL_POC`。WCBK1 已部署只读回调端点和 synthetic runner,公网结论 `PARTIAL_CALLBACK_NEEDS_CONFIG`,后续仍需配置真实回调并触发真实审批事件。WPROC1 后仍必须先做回调、补偿拉取和镜像数据模型门禁,不是直接进入审批镜像实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
| 通讯录 | 成员创建、读取、更新、禁用或删除 | 近期已验证 OA 写企微;后续改为企微主源同步 OA,OA 保留修复和审计 | `CAN_REUSE_NOW`:HCLT create/update/disable、部门读取和 WCPC 既有在职测试号 `contact.member_read` 均已通过 |
|
| 通讯录 | 成员创建、读取、更新、禁用或删除 | 近期已验证 OA 写企微;后续改为企微主源同步 OA,OA 保留修复和审计 | `CAN_REUSE_NOW`:HCLT create/update/disable、部门读取和 WCPC 既有在职测试号 `contact.member_read` 均已通过 |
|
||||||
| 人事助手 | 员工字段配置、花名册读取、花名册更新 | 作为员工主数据源,驱动 OA 员工、账号、角色、绑定和状态镜像 | `LOCAL_IMPLEMENTED_FOR_READ_SYNC`:字段配置和既有在职测试号花名册读取已通过;WHMS 已本地实现只读同步到 OA;资料更新写接口仍需单独 POC |
|
| 人事助手 | 员工字段配置、花名册读取、花名册更新 | 作为员工主数据源,驱动 OA 员工、账号、角色、绑定和状态镜像 | `LOCAL_IMPLEMENTED_FOR_READ_SYNC`:字段配置和既有在职测试号花名册读取已通过;WHMS 已本地实现只读同步到 OA;资料更新写接口仍需单独 POC |
|
||||||
| 打卡 | 打卡记录、员工规则、日报、月报、排班、补卡、规则管理 | 企微主办规则和采集,OA 同步按域镜像和应急对账 | `CAN_REUSE_NOW_FOR_READ`:WCPC 短窗口打卡记录和规则读取通过;日报/月报/排班等细项留到考勤镜像合同继续验证 |
|
| 打卡 | 打卡记录、员工规则、日报、月报、排班、补卡、规则管理 | 企微主办规则和采集,OA 同步按域镜像和应急对账 | `CAN_REUSE_NOW_FOR_READ`:WCPC 短窗口打卡记录和规则读取通过;日报/月报/排班等细项留到考勤镜像合同继续验证 |
|
||||||
| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WAPC2F_DRY_RUN_READY`:假期余额读取通过;WAPC1 已固化配置准备清单,WAPC2 已新增请假/补卡模板详情和审批详情只读 POC runner;WAPC2F 已新增样件 runner 并公网 dry-run 通过。真实模板/测试审批单 live 写入、WAPC2 自动复跑、真实回调、补偿拉取和镜像数据模型仍需在考勤/审批镜像前补齐 |
|
| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WCBK1_PARTIAL_CALLBACK_NEEDS_CONFIG`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已部署只读回调端点和 synthetic 验签/解密/解析 runner,但缺真实回调 URL、Token、EncodingAESKey 配置。补偿拉取和镜像数据模型仍需在考勤/审批镜像前补齐 |
|
||||||
| 工作台和消息 | 自建应用入口、应用消息、deeplink | 继续作为 OA 移动入口和通知渠道 | `CAN_REUSE_NOW`;WRV1 新增 `check:wecom-runtime-ready` 对入口、OAuth、脱敏消息 dry-run 和 deeplink 做运行就绪门禁 |
|
| 工作台和消息 | 自建应用入口、应用消息、deeplink | 继续作为 OA 移动入口和通知渠道 | `CAN_REUSE_NOW`;WRV1 新增 `check:wecom-runtime-ready` 对入口、OAuth、脱敏消息 dry-run 和 deeplink 做运行就绪门禁 |
|
||||||
| 财务/报销 | 企微不提供本项目需要的 PDF OCR、付款批次、银行回单和代账闭环 | OA 主办 | `KEEP_OA_PRIMARY` |
|
| 财务/报销 | 企微不提供本项目需要的 PDF OCR、付款批次、银行回单和代账闭环 | OA 主办 | `KEEP_OA_PRIMARY` |
|
||||||
| 真实支付、ERP、图片 OCR、银行流水导入 | 非本路线目标 | 不进入当前路线 | `NOT_IN_SCOPE` |
|
| 真实支付、ERP、图片 OCR、银行流水导入 | 非本路线目标 | 不进入当前路线 | `NOT_IN_SCOPE` |
|
||||||
@@ -247,7 +247,7 @@ WCP1 当前分类:
|
|||||||
| 人事助手资料更新 | `NEEDS_API_POC` | 属生产写接口,本轮只分类,不调用。 |
|
| 人事助手资料更新 | `NEEDS_API_POC` | 属生产写接口,本轮只分类,不调用。 |
|
||||||
| 打卡记录和规则读取 | `NEEDS_CONFIG` | 远端 `WECOM_CHECKIN_SYNC_ENABLED` 未开启,短窗口打卡和规则读取按配置阻断记录。 |
|
| 打卡记录和规则读取 | `NEEDS_CONFIG` | 远端 `WECOM_CHECKIN_SYNC_ENABLED` 未开启,短窗口打卡和规则读取按配置阻断记录。 |
|
||||||
| 审批模板、审批详情、假期余额 | `PARTIAL_NEEDS_CONFIG` | WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险测试审批单并复跑通过;外出/加班样例、真实回调、补偿拉取和镜像数据模型仍需后续合同。 |
|
| 审批模板、审批详情、假期余额 | `PARTIAL_NEEDS_CONFIG` | WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险测试审批单并复跑通过;外出/加班样例、真实回调、补偿拉取和镜像数据模型仍需后续合同。 |
|
||||||
| 回调配置 | `NEEDS_CONFIG` | 回调 URL、Token、AESKey 未作为可读配置注入,本轮不能证明回调链路。 |
|
| 回调配置 | `PARTIAL_CALLBACK_NEEDS_CONFIG` | WCBK1 代码和公网 synthetic dry-run 已通过;远端缺 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,尚不能证明企微后台 URL 验证或真实事件回调。 |
|
||||||
| 采购/合同/报销/财务审批 | `KEEP_OA_PRIMARY` | 继续 OA 主办,企业微信只做脱敏消息和 OA deeplink。 |
|
| 采购/合同/报销/财务审批 | `KEEP_OA_PRIMARY` | 继续 OA 主办,企业微信只做脱敏消息和 OA deeplink。 |
|
||||||
|
|
||||||
WCP1 历史结论不是 `GO_TO_HR_MASTER_SYNC`,但该阻断已由 WCPC 复跑收口。WCP1 报告继续作为初始配置缺口证据保留,当前 HR 主数据镜像准入以 WCPC 结论为准。
|
WCP1 历史结论不是 `GO_TO_HR_MASTER_SYNC`,但该阻断已由 WCPC 复跑收口。WCP1 报告继续作为初始配置缺口证据保留,当前 HR 主数据镜像准入以 WCPC 结论为准。
|
||||||
@@ -358,7 +358,7 @@ WMSG1 已完成企微应用消息实发与 Deeplink 真机确认。WAPC1 只把
|
|||||||
测试审批单和回调要求:
|
测试审批单和回调要求:
|
||||||
|
|
||||||
- 测试 `sp_no` 必须来自低风险样例,不包含采购、合同、报销、发票、银行回单、高敏附件或真实敏感业务资料。
|
- 测试 `sp_no` 必须来自低风险样例,不包含采购、合同、报销、发票、银行回单、高敏附件或真实敏感业务资料。
|
||||||
- 回调配置只记录 `WECOM_APPROVAL_CALLBACK_URL` / `WECOM_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY` / `WECOM_CALLBACK_AES_KEY` 等配置项名称;不得把 Token 或 EncodingAESKey 写入仓库、文档、Issue 或聊天。
|
- 回调配置只记录 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY` 等配置项名称;不得把 Token 或 EncodingAESKey 写入仓库、文档、Issue 或聊天。
|
||||||
- 后续 POC 必须证明模板详情读取、审批详情读取、回调 URL 验证、事件验签、消息解密、幂等键、企业微信重试语义和补偿拉取方案。
|
- 后续 POC 必须证明模板详情读取、审批详情读取、回调 URL 验证、事件验签、消息解密、幂等键、企业微信重试语义和补偿拉取方案。
|
||||||
- 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。
|
- 批量获取审批单号只作为补偿拉取候选,先标记 `NEEDS_API_POC`。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
**状态**:模块级开发规约
|
**状态**:模块级开发规约
|
||||||
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
|
**上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md)
|
||||||
|
|
||||||
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。后续仍必须先完成 WCBK1、WACP3 和 WADM1 门禁,不能直接放行镜像实现。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
|
本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已部署只读回调端点和 synthetic 验签/解密 runner,但真实回调配置仍缺,结论为 `PARTIAL_CALLBACK_NEEDS_CONFIG`。后续仍必须先完成 WCBK1 真实 URL/事件确认、WACP3 和 WADM1 门禁,不能直接放行镜像实现。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -413,7 +413,7 @@ WAPC2F 是 WAPC2 后的自动化优先补缺阶段,目标是尽量用 QA-only
|
|||||||
|
|
||||||
在 `wecom_attendance_approval_mirror_v1` 前必须补齐三个 POC/设计门禁:
|
在 `wecom_attendance_approval_mirror_v1` 前必须补齐三个 POC/设计门禁:
|
||||||
|
|
||||||
- `WCBK1 回调只读 POC`:证明真实回调 URL 验证、验签、解密、幂等键、重复投递和失败重试语义。
|
- `WCBK1 回调只读 POC`:已新增只读 `GET/POST /api/wecom/approval/callback` 和 `npm run check:wecom-approval-callback-poc`。当前公网 synthetic 验签、解密和解析通过,但缺 `WECOM_APPROVAL_CALLBACK_URL`、`WECOM_CALLBACK_TOKEN`、`WECOM_CALLBACK_ENCODING_AES_KEY`,结论 `PARTIAL_CALLBACK_NEEDS_CONFIG`;真实通过需企微后台 URL 验证和 `SMOKE101` 处理现有 OA-POC 测试单触发事件。
|
||||||
- `WACP3 补偿拉取 POC`:证明可按时间窗口获取审批单号,并调用 `getapprovaldetail` 拉取详情。
|
- `WACP3 补偿拉取 POC`:证明可按时间窗口获取审批单号,并调用 `getapprovaldetail` 拉取详情。
|
||||||
- `WADM1 镜像数据模型设计`:先设计字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。
|
- `WADM1 镜像数据模型设计`:先设计字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md)
|
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md)
|
||||||
**体验总依据**:[产品设计规则基线](./产品设计规则.md)
|
**体验总依据**:[产品设计规则基线](./产品设计规则.md)
|
||||||
|
|
||||||
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WPROC1 后还必须先完成 WAPC2F 自动样件、WAPC2 自动复跑、WCBK1 回调只读 POC、WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计,这不代表真实回调或审批镜像已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
|
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WAPC2FLIVE 已生成 OA-POC 样件并复跑通过;WCBK1 已部署回调只读端点和 synthetic runner,但真实 URL 验证和事件确认仍待配置。后续还必须先完成 WCBK1 真实确认、WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计,这不代表真实回调或审批镜像已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ WFOC 后,正常审批规则优先以企业微信审批模板和打卡/假期
|
|||||||
- 当前 A0.6:`WAPC2 请假/补卡只读 POC` 已实现 runner;缺模板 ID 或低风险 `sp_no` 时保持 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`。
|
- 当前 A0.6:`WAPC2 请假/补卡只读 POC` 已实现 runner;缺模板 ID 或低风险 `sp_no` 时保持 `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`。
|
||||||
- 后续 A0.7:`WAPC2F 自动生成 POC 样件`,默认申请人 `SMOKE002`、审批人 `SMOKE101`,必须通过非自审预检;若 `SMOKE101` 不可用,不自动换人。
|
- 后续 A0.7:`WAPC2F 自动生成 POC 样件`,默认申请人 `SMOKE002`、审批人 `SMOKE101`,必须通过非自审预检;若 `SMOKE101` 不可用,不自动换人。
|
||||||
- 后续 A0.8:`WAPC2 自动复跑`,用自动样件的脱敏模板 ID 和 `sp_no` 复跑只读 POC。
|
- 后续 A0.8:`WAPC2 自动复跑`,用自动样件的脱敏模板 ID 和 `sp_no` 复跑只读 POC。
|
||||||
- 后续 A0.9:`WCBK1 回调只读 POC` 和 `WACP3 补偿拉取 POC`,证明真实回调验签/解密/幂等和按时间窗口拉审批单号/详情。
|
- 后续 A0.9:`WCBK1 回调只读 POC` 和 `WACP3 补偿拉取 POC`,WCBK1 代码/synthetic 已部署但仍需管理员配置后证明真实回调验签/解密/幂等,随后证明按时间窗口拉审批单号/详情。
|
||||||
- 后续 A0.10:`WADM1 镜像数据模型设计`,先设计字段、状态机、幂等键、索引、隐私保留和迁移回滚。
|
- 后续 A0.10:`WADM1 镜像数据模型设计`,先设计字段、状态机、幂等键、索引、隐私保留和迁移回滚。
|
||||||
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险 `sp_no` 样例;正式开发前仍必须补齐外出/加班样例或处理决策、真实回调配置、回调验签/解密/幂等、补偿拉取、镜像数据模型和必要打卡细项 POC。
|
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。WAPC2FLIVE 已生成请假/补卡 OA-POC 模板和低风险 `sp_no` 样例;正式开发前仍必须补齐外出/加班样例或处理决策、真实回调配置、回调验签/解密/幂等、补偿拉取、镜像数据模型和必要打卡细项 POC。
|
||||||
- 后续 A2:企微打卡规则、排班、记录、日报、月报按域镜像,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。
|
- 后续 A2:企微打卡规则、排班、记录、日报、月报按域镜像,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
|
WCBK1-0,回调只读 POC 合同建档与边界冻结,"建立 WCBK1 企微审批回调只读 POC 合同;承接 WAPC2FLIVE 已创建 OA-POC 请假/补卡测试模板和低风险测试审批单,下一步只验证真实回调 URL、验签、解密、归一化、幂等和脱敏证据,不进入 wecom_attendance_approval_mirror_v1。","1) CSV 校验通过;2) 固定只读范围为请假 + 补卡;3) 固定 WECOM_APPROVAL_CALLBACK_URL、WECOM_CALLBACK_TOKEN、WECOM_CALLBACK_ENCODING_AES_KEY;4) 回调属于自建应用审批能力,不使用通讯录 Secret,不尝试其他 Secret,不允许运行时猜 Secret;5) 缺配置记录 NEEDS_CONFIG,企微权限/可信 IP/可见范围/验签/解密阻断记录 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;6) 不写 OA 业务镜像、不写企微数据、不创建新模板或审批单;7) 不保存原始 XML、密文、完整 sp_no、完整 userid、完整 template_id、Token、EncodingAESKey、表单值、附件、评论或原始 payload。","受影响流程:WAPC2FLIVE 样件 -> WCBK1 回调只读 POC -> WACP3 补偿拉取 POC -> WADM1 镜像数据模型设计;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv --profile wecom-callback --json。",python3|node,DONE,DONE,DONE,issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv,[],回滚/清理:本轮只新增只读回调端点、QA runner 和脱敏证据文件;CSV 与 wecom-callback process gate 已通过;如失败可停用回调 URL 或移除新增路由,不影响已保留审计的 OA-POC 样件。
|
||||||
|
WCBK1-1,process gate 增加企微回调 profile,"扩展 scripts/qa/check-process-contract.mjs,新增 wecom-callback profile,强制检查回调 URL、Token、EncodingAESKey、只读范围、缺配置分类、无 Secret fallback、脱敏边界和不得跳过 WACP3/WADM1。","1) --profile wecom-callback 可解析并通过本 CSV;2) baseline gate 继续通过;3) 旧 wecom-approval-fixture profile 不回归;4) 关键词 WCBK1、WACP3、WADM1、WECOM_CALLBACK_TOKEN、WECOM_CALLBACK_ENCODING_AES_KEY、BLOCKED_BY_EXTERNAL_CONFIG 被覆盖。","受影响流程:Issue CSV -> process gate -> 企微高风险能力准入;验证步骤:node --check scripts/qa/check-process-contract.mjs;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv --profile wecom-callback --json;cd server && npx vitest run tests/unit/scripts/check-process-contract.test.js --reporter=verbose。",node|vitest,DONE,DONE,DONE,scripts/qa/check-process-contract.mjs|server/tests/unit/scripts/check-process-contract.test.js,"[""WCBK1-0""]",回滚/清理:process gate 为 QA-only 脚本,不影响运行时;失败时移除 wecom-callback profile 或修正规则。
|
||||||
|
WCBK1-2,企微回调加解密服务,"新增 wecomCallbackCrypto 服务,按企业微信回调规则实现 msg_signature 验签、EncodingAESKey AES 解密、URL 验证 echostr 解密、事件 XML 解析、审批事件归一化和脱敏摘要。","1) 使用 WECOM_CALLBACK_TOKEN 和 WECOM_CALLBACK_ENCODING_AES_KEY;2) 签名按 token/timestamp/nonce/encrypt 排序后 SHA1;3) AES 解密校验 receiveId 与 WECOM_CORP_ID;4) XML 解析不输出原始 XML 或密文;5) 审批事件只归一化事件类型、审批状态、脱敏 sp_no/template 前缀和时间摘要;6) 缺配置或解密失败分类清晰。","受影响流程:企微加密回调 -> 服务端验签解密 -> 只读事件摘要;验证步骤:cd server && node --check src/services/integrations/wecomCallbackCrypto.js;cd server && npx vitest run tests/unit/services/wecomCallbackCrypto.test.js --reporter=verbose。",node|vitest,DONE,DONE,DONE,server/src/services/integrations/wecomCallbackCrypto.js|server/tests/unit/services/wecomCallbackCrypto.test.js,"[""WCBK1-1""]",回滚/清理:新增服务无业务写入;如失败可移除服务和调用点。
|
||||||
|
WCBK1-3,只读回调路由,"新增 /api/wecom/approval/callback GET/POST 路由;GET 用于企微 URL 验证,POST 接收审批状态变化事件,验签解密后只写脱敏 POC 证据,不落业务镜像表。","1) GET /api/wecom/approval/callback 能返回解密后的 echostr;2) POST /api/wecom/approval/callback 验签/解密/解析成功后返回 success;3) 错误签名返回阻断,不伪造成成功;4) 只写 artifacts/qa-flow 下脱敏事件证据,不保存原始 XML、密文、完整 sp_no、完整 userid、完整 template_id、Token、EncodingAESKey、表单值、附件、评论或原始 payload;5) 重复事件按幂等键去重;6) 不要求登录鉴权,避免企微回调被 OA 登录拦截。","受影响流程:企微后台回调 URL 验证 -> 真实审批状态回调 -> 脱敏证据留存;验证步骤:cd server && node --check src/routes/wecomApprovalCallback.js;cd server && npx vitest run tests/unit/routes/wecomApprovalCallback.test.js --reporter=verbose。",node|vitest,DONE,DONE,DONE,server/src/routes/wecomApprovalCallback.js|server/src/app.js|server/tests/unit/routes/wecomApprovalCallback.test.js,"[""WCBK1-2""]",回滚/清理:如公网验证失败,可从企微后台移除 Callback URL 或删除新增路由;证据文件只含脱敏 POC 摘要。
|
||||||
|
WCBK1-4,QA-only 回调 POC runner,"新增 check-wecom-approval-callback-poc runner 和 package 脚本;默认 synthetic/dry-run 本地生成加密样例验证验签、解密、解析和脱敏;live 模式检查公网 URL 验证和真实事件证据。","1) npm run check:wecom-approval-callback-poc 可执行;2) 缺 WECOM_APPROVAL_CALLBACK_URL、WECOM_CALLBACK_TOKEN 或 WECOM_CALLBACK_ENCODING_AES_KEY 时输出 PARTIAL_CALLBACK_NEEDS_CONFIG;3) live URL 验证通过输出 PASS_CALLBACK_URL_VERIFIED;4) 真实事件证据存在后输出 PASS_CALLBACK_EVENT_CONFIRMED;5) 验签/解密/公网阻断输出 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;6) 报告不泄露密钥、原始 XML、完整 sp_no 或完整 userid。","受影响流程:本地 synthetic 回调验证 -> 公网 URL 验证 -> 真实审批事件证据检查;验证步骤:cd server && node --check src/scripts/check-wecom-approval-callback-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-callback-poc.test.js --reporter=verbose。",node|npm|vitest,DONE,DONE,DONE,server/src/scripts/check-wecom-approval-callback-poc.js|server/package.json|server/tests/unit/scripts/check-wecom-approval-callback-poc.test.js,"[""WCBK1-3""]",回滚/清理:runner 为 QA-only;失败时不影响业务运行,也不会写 OA/企微业务数据。
|
||||||
|
WCBK1-5,配置口径与缺口分类,"在 server config 和 runtime ready 事实口径中固化 WECOM_APPROVAL_CALLBACK_URL、WECOM_CALLBACK_TOKEN、WECOM_CALLBACK_ENCODING_AES_KEY;不得使用通讯录 Secret 或历史别名作为默认。","1) config 可读取三个回调变量;2) 新 WCBK1 代码只读取固定变量,不尝试其他 Secret;3) 缺变量统一分类为 PARTIAL_CALLBACK_NEEDS_CONFIG 或 NEEDS_CONFIG;4) 文档提示密钥仅进部署环境,不写仓库或聊天。","受影响流程:部署环境配置 -> 回调 POC runner -> 公网回调验证;验证步骤:cd server && node --check src/config.js;rg -n 'WECOM_APPROVAL_CALLBACK_URL|WECOM_CALLBACK_TOKEN|WECOM_CALLBACK_ENCODING_AES_KEY|WECOM_CALLBACK_AES_KEY' server/src docs .agents/skills/hyb-oa-delivery/SKILL.md。",node|rg,DONE,DONE,DONE,server/src/config.js|docs/企微API能力目录.md,"[""WCBK1-4""]",回滚/清理:新增配置字段为空不启用写入;不持久化密钥值。
|
||||||
|
WCBK1-6,事实源回写,"更新路线图、API 能力目录、统一审批规约、企业微信基础设施规约、试运行报告、文档索引、事实源矩阵和项目 skill,记录 WCBK1 的只读边界、配置项、报告语义和后续 WACP3/WADM1 顺序。","1) 文档明确 WCBK1 只证明回调链路,不代表镜像实现;2) 文档明确 WCBK1 后仍需 WACP3 补偿拉取 POC 和 WADM1 镜像数据模型设计;3) 文档不保存 Token、EncodingAESKey、完整 sp_no、完整 userid 或原始 XML;4) 文档索引和事实源矩阵登记本轮状态。","受影响流程:WCBK1 证据回写 -> 后续路线判断;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;python3 /home/shunli/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/hyb-oa-delivery;rg -n 'WCBK1|PASS_CALLBACK_URL_VERIFIED|PASS_CALLBACK_EVENT_CONFIRMED|WACP3|WADM1|WECOM_CALLBACK_ENCODING_AES_KEY' docs .agents/skills/hyb-oa-delivery/SKILL.md。",node|python3|rg,DONE,DONE,DONE,docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-统一审批中心规约.md|docs/需求-企业微信基础设施规约.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md,"[""WCBK1-5""]",回滚/清理:文档只记录事实源和脱敏报告语义,不写密钥或原始回调。
|
||||||
|
WCBK1-7,本地门禁与公网 dry-run 部署,"执行本地静态、focused vitest、process gate、文档门禁和最小门禁;通过后以 SYNC_ENV=0 受控部署,远端执行 runner synthetic/dry-run,不同步本地 env、不配置真实密钥。","1) CSV、process gate、node --check、Prisma、focused vitest、文档审计、skill 校验、git diff --check、scripts/check-minimal.sh 通过;2) SYNC_ENV=0 部署成功;3) /ready 正常;4) 远端 runner 在未配置真实回调密钥时输出 PARTIAL_CALLBACK_NEEDS_CONFIG 且 synthetic check OK,不伪造成 PASS。","受影响流程:本地实现 -> 受控部署 -> 公网 dry-run 证据;验证步骤:cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/routes/wecomApprovalCallback.test.js tests/unit/services/wecomCallbackCrypto.test.js tests/unit/scripts/check-wecom-approval-callback-poc.test.js tests/unit/scripts/check-process-contract.test.js --reporter=verbose;git diff --check;bash scripts/check-minimal.sh;SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端 npm run check:wecom-approval-callback-poc -- --format=json。",node|npm|vitest|bash|ssh,DONE,DONE,DONE,external,"[""WCBK1-6""]",回滚/清理:本地 CSV/process gate/node --check/Prisma/focused vitest/git diff --check/scripts/check-minimal.sh 均通过;SYNC_ENV=0 部署成功;/ready 正常;远端报告 /app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T065414Z/summary.json 为 PARTIAL_CALLBACK_NEEDS_CONFIG 且 synthetic OK;未配置真实回调密钥时不会接收真实事件。
|
||||||
|
WCBK1-8,状态回填、提交与人工配置说明,"按实际结果回填 WCBK1 状态并中文提交;最终输出 Callback URL、需要配置的环境变量名和人工触发步骤。","1) Issue 状态按实际结果回填;2) 中文提交为 WCBK1 实现企微审批回调只读 POC;3) 最终说明当前状态、改了什么、测试了什么、结果是什么、部署状态、Callback URL、环境变量名、企微后台配置步骤和 SMOKE101 处理现有测试单的人工动作;4) 不输出任何密钥值。","受影响流程:WCBK1 交付闭环 -> 人工配置回调 -> 后续真实事件确认;验证步骤:git status --short;git log --oneline -1;最终问答说明配置后复跑 live runner 和真实事件触发步骤。",git,DONE,DONE,DONE,N/A,"[""WCBK1-7""]",回滚/清理:状态已按实际结果回填;如用户暂不配置回调,WCBK1 停留在 dry-run/NEEDS_CONFIG 状态;不影响已运行的入口、消息、WAPC2FLIVE 样件和现有业务。
|
||||||
|
@@ -22,14 +22,14 @@ const REQUIRED_COLUMNS = [
|
|||||||
"Notes",
|
"Notes",
|
||||||
];
|
];
|
||||||
const VALID_STATUSES = new Set(["TODO", "DOING", "DONE"]);
|
const VALID_STATUSES = new Set(["TODO", "DOING", "DONE"]);
|
||||||
const VALID_PROFILES = new Set(["general", "wecom", "wecom-approval-fixture", "docs-only"]);
|
const VALID_PROFILES = new Set(["general", "wecom", "wecom-approval-fixture", "wecom-callback", "docs-only"]);
|
||||||
const WAPC2F_ISSUE = "issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv";
|
const WAPC2F_ISSUE = "issues/2026-05-19_10-24-43-wecom-approval-fixture-auto-generate-v1.csv";
|
||||||
const WPROC2_ISSUE = "issues/2026-05-19_11-15-10-process-gate-automation-v1.csv";
|
const WPROC2_ISSUE = "issues/2026-05-19_11-15-10-process-gate-automation-v1.csv";
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return `Usage:
|
return `Usage:
|
||||||
node scripts/qa/check-process-contract.mjs --baseline [--json]
|
node scripts/qa/check-process-contract.mjs --baseline [--json]
|
||||||
node scripts/qa/check-process-contract.mjs --issue <csv> --profile <general|wecom|wecom-approval-fixture|docs-only> [--json]
|
node scripts/qa/check-process-contract.mjs --issue <csv> --profile <general|wecom|wecom-approval-fixture|wecom-callback|docs-only> [--json]
|
||||||
|
|
||||||
This QA-only process gate is read-only. It validates Issue CSV contracts and
|
This QA-only process gate is read-only. It validates Issue CSV contracts and
|
||||||
current WeCom process facts without printing secrets or raw external payloads.`;
|
current WeCom process facts without printing secrets or raw external payloads.`;
|
||||||
@@ -330,6 +330,67 @@ function validateWecomApprovalFixture({ result, rows }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateWecomCallback({ result, rows }) {
|
||||||
|
validateWecom({ result, rows });
|
||||||
|
const text = textOfRows(rows);
|
||||||
|
|
||||||
|
const requiredTerms = [
|
||||||
|
"WCBK1",
|
||||||
|
"GET /api/wecom/approval/callback",
|
||||||
|
"POST /api/wecom/approval/callback",
|
||||||
|
"WECOM_APPROVAL_CALLBACK_URL",
|
||||||
|
"WECOM_CALLBACK_TOKEN",
|
||||||
|
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||||
|
"msg_signature",
|
||||||
|
"EncodingAESKey",
|
||||||
|
"验签",
|
||||||
|
"解密",
|
||||||
|
"幂等",
|
||||||
|
"只读",
|
||||||
|
"NEEDS_CONFIG",
|
||||||
|
"SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||||
|
"WACP3",
|
||||||
|
"WADM1",
|
||||||
|
];
|
||||||
|
for (const term of requiredTerms) {
|
||||||
|
if (!hasTerm(text, term)) {
|
||||||
|
addError(result, "callback.required_term", `WCBK1 contract must mention ${term}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAny(text, ["不使用通讯录 Secret", "不尝试其他 Secret", "不允许运行时猜 Secret", "无 fallback"])) {
|
||||||
|
addError(result, "callback.secret_source", "WCBK1 must disallow Secret fallback and runtime guessing");
|
||||||
|
}
|
||||||
|
if (!hasAny(text, ["不写 OA 业务镜像", "不落业务镜像表", "不写 OA/企微业务数据", "不写企微数据"])) {
|
||||||
|
addError(result, "callback.read_only", "WCBK1 must declare no OA/WeCom business writes");
|
||||||
|
}
|
||||||
|
|
||||||
|
const privacyTerms = [
|
||||||
|
"原始 XML",
|
||||||
|
"密文",
|
||||||
|
"完整 sp_no",
|
||||||
|
"完整 userid",
|
||||||
|
"完整 template_id",
|
||||||
|
"Token",
|
||||||
|
"EncodingAESKey",
|
||||||
|
"原始 payload",
|
||||||
|
];
|
||||||
|
for (const term of privacyTerms) {
|
||||||
|
if (!hasTerm(text, term)) {
|
||||||
|
addError(result, "callback.privacy", `WCBK1 contract must declare no ${term} in reports`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routePattern = /WCBK1[\s\S]{0,260}WACP3[\s\S]{0,260}WADM1/;
|
||||||
|
if (!routePattern.test(text)) {
|
||||||
|
addError(result, "callback.route_order", "WCBK1 route must show WACP3 and WADM1 before mirror implementation");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.errors.some((item) => item.code.startsWith("callback."))) {
|
||||||
|
addCheck(result, "profile.wecom_callback", "PASS", "WCBK1 callback config, crypto, read-only, route and privacy gates are declared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function evaluateIssueContract({ csvText, issuePath = "", profile = "general" }) {
|
export function evaluateIssueContract({ csvText, issuePath = "", profile = "general" }) {
|
||||||
const result = createResult(issuePath || "inline issue", profile);
|
const result = createResult(issuePath || "inline issue", profile);
|
||||||
const { header, rows } = parseIssueCsv(csvText);
|
const { header, rows } = parseIssueCsv(csvText);
|
||||||
@@ -341,6 +402,8 @@ export function evaluateIssueContract({ csvText, issuePath = "", profile = "gene
|
|||||||
validateWecom({ result, rows });
|
validateWecom({ result, rows });
|
||||||
} else if (profile === "wecom-approval-fixture") {
|
} else if (profile === "wecom-approval-fixture") {
|
||||||
validateWecomApprovalFixture({ result, rows });
|
validateWecomApprovalFixture({ result, rows });
|
||||||
|
} else if (profile === "wecom-callback") {
|
||||||
|
validateWecomCallback({ result, rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+1
@@ -28,6 +28,7 @@
|
|||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"fast-xml-parser": "5.3.4",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"audit:org-master-data": "node src/scripts/audit-org-master-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",
|
"audit:wecom-oa-users": "node src/scripts/audit-wecom-oa-user-consistency.js",
|
||||||
"check:wecom-approval-fixtures": "node src/scripts/check-wecom-approval-fixtures.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-poc": "node src/scripts/check-wecom-approval-poc.js",
|
"check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js",
|
||||||
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
|
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
|
||||||
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
|
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
|
"fast-xml-parser": "5.3.4",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const prismaPlugin = require("./plugins/prisma");
|
|||||||
const swaggerDocsPlugin = require("./plugins/swagger");
|
const swaggerDocsPlugin = require("./plugins/swagger");
|
||||||
const authRoutes = require("./routes/auth");
|
const authRoutes = require("./routes/auth");
|
||||||
const wecomAuthRoutes = require("./routes/wecomAuth");
|
const wecomAuthRoutes = require("./routes/wecomAuth");
|
||||||
|
const wecomApprovalCallbackRoutes = require("./routes/wecomApprovalCallback");
|
||||||
const meRoutes = require("./routes/me");
|
const meRoutes = require("./routes/me");
|
||||||
const reimbursementRoutes = require("./routes/reimbursements");
|
const reimbursementRoutes = require("./routes/reimbursements");
|
||||||
const invoicePoolRoutes = require("./routes/invoicePool");
|
const invoicePoolRoutes = require("./routes/invoicePool");
|
||||||
@@ -336,6 +337,7 @@ async function buildApp() {
|
|||||||
|
|
||||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||||
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||||
|
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api" });
|
||||||
await app.register(eventsRoutes, { prefix: "/api" });
|
await app.register(eventsRoutes, { prefix: "/api" });
|
||||||
await app.register(meRoutes, { prefix: "/api" });
|
await app.register(meRoutes, { prefix: "/api" });
|
||||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ function getConfig() {
|
|||||||
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
||||||
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
||||||
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
||||||
|
wecomApprovalCallbackUrl: process.env.WECOM_APPROVAL_CALLBACK_URL ?? "",
|
||||||
|
wecomCallbackToken: process.env.WECOM_CALLBACK_TOKEN ?? "",
|
||||||
|
wecomCallbackEncodingAesKey: process.env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "",
|
||||||
|
wecomApprovalCallbackEvidenceFile: process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE ?? "",
|
||||||
|
|
||||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||||
storageDir: path.resolve(serverRoot, storageDir),
|
storageDir: path.resolve(serverRoot, storageDir),
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
const {
|
||||||
|
WecomCallbackError,
|
||||||
|
assertWecomSignature,
|
||||||
|
decryptWecomMessage,
|
||||||
|
extractEncryptedFromXml,
|
||||||
|
maskWecomIdentifier,
|
||||||
|
normalizeApprovalCallbackEvent,
|
||||||
|
recordCallbackEvidence,
|
||||||
|
sanitizeErrorMessage,
|
||||||
|
validateCallbackConfig,
|
||||||
|
} = require("../services/integrations/wecomCallbackCrypto");
|
||||||
|
|
||||||
|
function toSafeString(value, maxLength = 500) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function callbackConfig(fastify) {
|
||||||
|
const config = fastify.config || {};
|
||||||
|
return {
|
||||||
|
receiveId: config.wecomCorpId,
|
||||||
|
token: config.wecomCallbackToken,
|
||||||
|
encodingAesKey: config.wecomCallbackEncodingAesKey,
|
||||||
|
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPlain(reply, statusCode, body) {
|
||||||
|
return reply.status(statusCode).type("text/plain; charset=utf-8").send(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyCallbackError(error) {
|
||||||
|
if (error instanceof WecomCallbackError) {
|
||||||
|
if (error.code === "invalid_msg_signature") return { statusCode: 403, body: "invalid signature" };
|
||||||
|
if (error.code === "receive_id_mismatch") return { statusCode: 403, body: "receive id mismatch" };
|
||||||
|
return { statusCode: 400, body: "invalid callback payload" };
|
||||||
|
}
|
||||||
|
return { statusCode: 500, body: "callback error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSignatureQuery(request) {
|
||||||
|
const query = request.query || {};
|
||||||
|
return {
|
||||||
|
signature: toSafeString(query.msg_signature, 120),
|
||||||
|
timestamp: toSafeString(query.timestamp, 80),
|
||||||
|
nonce: toSafeString(query.nonce, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wecomApprovalCallbackRoutes(fastify, options = {}) {
|
||||||
|
fastify.addContentTypeParser(
|
||||||
|
["text/xml", "application/xml", "application/octet-stream"],
|
||||||
|
{ parseAs: "string" },
|
||||||
|
(request, body, done) => done(null, body),
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.addContentTypeParser(
|
||||||
|
/^application\/.*\+xml$/i,
|
||||||
|
{ parseAs: "string" },
|
||||||
|
(request, body, done) => done(null, body),
|
||||||
|
);
|
||||||
|
|
||||||
|
fastify.get("/wecom/approval/callback", async (request, reply) => {
|
||||||
|
const runtimeConfig = callbackConfig(fastify);
|
||||||
|
const configCheck = validateCallbackConfig({
|
||||||
|
wecomCorpId: runtimeConfig.receiveId,
|
||||||
|
wecomCallbackToken: runtimeConfig.token,
|
||||||
|
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
|
||||||
|
});
|
||||||
|
if (!configCheck.ok) {
|
||||||
|
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
|
||||||
|
return sendPlain(reply, 503, "callback config missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = readSignatureQuery(request);
|
||||||
|
const encrypted = toSafeString(request.query?.echostr, 8192);
|
||||||
|
try {
|
||||||
|
assertWecomSignature({
|
||||||
|
token: runtimeConfig.token,
|
||||||
|
timestamp: query.timestamp,
|
||||||
|
nonce: query.nonce,
|
||||||
|
encrypted,
|
||||||
|
signature: query.signature,
|
||||||
|
});
|
||||||
|
const decrypted = decryptWecomMessage({
|
||||||
|
encrypted,
|
||||||
|
encodingAesKey: runtimeConfig.encodingAesKey,
|
||||||
|
receiveId: runtimeConfig.receiveId,
|
||||||
|
});
|
||||||
|
return sendPlain(reply, 200, decrypted.plainXml);
|
||||||
|
} catch (error) {
|
||||||
|
const classified = classifyCallbackError(error);
|
||||||
|
fastify.log.warn({
|
||||||
|
code: error?.code || "callback_url_verify_failed",
|
||||||
|
message: sanitizeErrorMessage(error?.message || error),
|
||||||
|
}, "WeCom approval callback URL verification failed");
|
||||||
|
return sendPlain(reply, classified.statusCode, classified.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/wecom/approval/callback", async (request, reply) => {
|
||||||
|
const runtimeConfig = callbackConfig(fastify);
|
||||||
|
const configCheck = validateCallbackConfig({
|
||||||
|
wecomCorpId: runtimeConfig.receiveId,
|
||||||
|
wecomCallbackToken: runtimeConfig.token,
|
||||||
|
wecomCallbackEncodingAesKey: runtimeConfig.encodingAesKey,
|
||||||
|
});
|
||||||
|
if (!configCheck.ok) {
|
||||||
|
fastify.log.warn({ missing: configCheck.missing }, "WeCom approval callback config missing");
|
||||||
|
return sendPlain(reply, 503, "callback config missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = readSignatureQuery(request);
|
||||||
|
const body = toSafeString(request.body, 20000);
|
||||||
|
try {
|
||||||
|
const encrypted = extractEncryptedFromXml(body);
|
||||||
|
assertWecomSignature({
|
||||||
|
token: runtimeConfig.token,
|
||||||
|
timestamp: query.timestamp,
|
||||||
|
nonce: query.nonce,
|
||||||
|
encrypted,
|
||||||
|
signature: query.signature,
|
||||||
|
});
|
||||||
|
const decrypted = decryptWecomMessage({
|
||||||
|
encrypted,
|
||||||
|
encodingAesKey: runtimeConfig.encodingAesKey,
|
||||||
|
receiveId: runtimeConfig.receiveId,
|
||||||
|
});
|
||||||
|
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
|
||||||
|
const evidenceStore = options.evidenceStore || {
|
||||||
|
record: (eventSummary) => recordCallbackEvidence(eventSummary, { evidenceFile: runtimeConfig.evidenceFile }),
|
||||||
|
};
|
||||||
|
const stored = await evidenceStore.record(summary);
|
||||||
|
fastify.log.info({
|
||||||
|
eventType: summary.eventType,
|
||||||
|
callbackKind: summary.callbackKind,
|
||||||
|
approvalType: summary.approvalType,
|
||||||
|
statusLabel: summary.statusLabel,
|
||||||
|
spNoPrefix: summary.spNoPrefix,
|
||||||
|
templateIdPrefix: summary.templateIdPrefix,
|
||||||
|
idempotencyKey: maskWecomIdentifier(summary.idempotencyKey),
|
||||||
|
duplicate: Boolean(stored?.duplicate),
|
||||||
|
}, "WeCom approval callback event accepted");
|
||||||
|
return sendPlain(reply, 200, "success");
|
||||||
|
} catch (error) {
|
||||||
|
const classified = classifyCallbackError(error);
|
||||||
|
fastify.log.warn({
|
||||||
|
code: error?.code || "callback_event_failed",
|
||||||
|
message: sanitizeErrorMessage(error?.message || error),
|
||||||
|
}, "WeCom approval callback event rejected");
|
||||||
|
return sendPlain(reply, classified.statusCode, classified.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
wecomApprovalCallbackRoutes.__testables = {
|
||||||
|
classifyCallbackError,
|
||||||
|
readSignatureQuery,
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = wecomApprovalCallbackRoutes;
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
WecomCallbackError,
|
||||||
|
buildEncryptedApprovalEventSample,
|
||||||
|
buildUrlVerificationSample,
|
||||||
|
decryptWecomMessage,
|
||||||
|
extractEncryptedFromXml,
|
||||||
|
maskWecomIdentifier,
|
||||||
|
normalizeApprovalCallbackEvent,
|
||||||
|
readCallbackEvidence,
|
||||||
|
validateCallbackConfig,
|
||||||
|
verifyWecomSignature,
|
||||||
|
} = require("../services/integrations/wecomCallbackCrypto");
|
||||||
|
|
||||||
|
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||||
|
const SYNTHETIC_TOKEN = "wcbk1-synthetic-token";
|
||||||
|
const SYNTHETIC_AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||||
|
const SYNTHETIC_CORP_ID = "wwsyntheticcallbackcorp";
|
||||||
|
const SYNTHETIC_SP_NO = "SPWCBK1SYNTHETIC000001";
|
||||||
|
const SYNTHETIC_TEMPLATE_ID = "TPLWCBK1SYNTHETIC000001";
|
||||||
|
|
||||||
|
function timestamp() {
|
||||||
|
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return `Usage: node src/scripts/check-wecom-approval-callback-poc.js [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--live 访问已部署 Callback URL 做 URL 验证,并检查真实事件证据。
|
||||||
|
--format=json 输出 JSON 到 stdout。
|
||||||
|
--callback-url=<url> 临时覆盖 WECOM_APPROVAL_CALLBACK_URL。
|
||||||
|
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-callback-poc-<timestamp>。
|
||||||
|
--help 显示帮助。
|
||||||
|
|
||||||
|
该 runner 为 QA-only,只输出脱敏摘要,不打印 Token、EncodingAESKey、原始 XML、密文、完整 sp_no 或完整 userid。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv = process.argv.slice(2), env = process.env) {
|
||||||
|
const options = {
|
||||||
|
live: false,
|
||||||
|
format: "text",
|
||||||
|
callbackUrl: "",
|
||||||
|
reportDir: env.WECOM_APPROVAL_CALLBACK_POC_REPORT_DIR
|
||||||
|
|| path.join(DEFAULT_REPORT_ROOT, `wecom-approval-callback-poc-${timestamp()}`),
|
||||||
|
help: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--live") {
|
||||||
|
options.live = true;
|
||||||
|
} else if (arg === "--format=json" || arg === "--json") {
|
||||||
|
options.format = "json";
|
||||||
|
} 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 === "--callback-url") {
|
||||||
|
options.callbackUrl = argv[index + 1] || "";
|
||||||
|
index += 1;
|
||||||
|
} else if (arg.startsWith("--callback-url=")) {
|
||||||
|
options.callbackUrl = arg.slice("--callback-url=".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 === "--help" || arg === "-h") {
|
||||||
|
options.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["text", "json"].includes(options.format)) {
|
||||||
|
throw new Error(`unsupported format: ${options.format}`);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configured(value) {
|
||||||
|
return String(value ?? "").trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskHostLike(url) {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCallbackConfig(env = process.env) {
|
||||||
|
return {
|
||||||
|
wecomCorpId: env.WECOM_CORP_ID || "",
|
||||||
|
wecomApprovalCallbackUrl: env.WECOM_APPROVAL_CALLBACK_URL || "",
|
||||||
|
wecomCallbackToken: env.WECOM_CALLBACK_TOKEN || "",
|
||||||
|
wecomCallbackEncodingAesKey: env.WECOM_CALLBACK_ENCODING_AES_KEY || "",
|
||||||
|
wecomApprovalCallbackEvidenceFile: env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || "",
|
||||||
|
wecomRequestTimeoutMs: Number(env.WECOM_REQUEST_TIMEOUT_MS || 10000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCallbackConfigCheck(config, callbackUrl) {
|
||||||
|
const missing = [];
|
||||||
|
if (!configured(config.wecomCorpId)) missing.push("WECOM_CORP_ID");
|
||||||
|
if (!configured(callbackUrl)) missing.push("WECOM_APPROVAL_CALLBACK_URL");
|
||||||
|
if (!configured(config.wecomCallbackToken)) missing.push("WECOM_CALLBACK_TOKEN");
|
||||||
|
if (!configured(config.wecomCallbackEncodingAesKey)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "callback_config",
|
||||||
|
status: missing.length ? "NEEDS_CONFIG" : "OK",
|
||||||
|
missing,
|
||||||
|
configured: {
|
||||||
|
corpId: configured(config.wecomCorpId),
|
||||||
|
callbackUrl: configured(callbackUrl),
|
||||||
|
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
|
||||||
|
token: configured(config.wecomCallbackToken),
|
||||||
|
encodingAesKey: configured(config.wecomCallbackEncodingAesKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSyntheticCheck(config = {}) {
|
||||||
|
const token = configured(config.wecomCallbackToken) ? config.wecomCallbackToken : SYNTHETIC_TOKEN;
|
||||||
|
const encodingAesKey = configured(config.wecomCallbackEncodingAesKey)
|
||||||
|
? config.wecomCallbackEncodingAesKey
|
||||||
|
: SYNTHETIC_AES_KEY;
|
||||||
|
const receiveId = configured(config.wecomCorpId) ? config.wecomCorpId : SYNTHETIC_CORP_ID;
|
||||||
|
const timestampValue = "1770000000";
|
||||||
|
const nonce = "wcbk1-synthetic";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sample = buildEncryptedApprovalEventSample({
|
||||||
|
token,
|
||||||
|
encodingAesKey,
|
||||||
|
receiveId,
|
||||||
|
timestamp: timestampValue,
|
||||||
|
nonce,
|
||||||
|
spNo: SYNTHETIC_SP_NO,
|
||||||
|
templateId: SYNTHETIC_TEMPLATE_ID,
|
||||||
|
});
|
||||||
|
const encrypted = extractEncryptedFromXml(sample.encryptedXml);
|
||||||
|
const signatureOk = verifyWecomSignature({
|
||||||
|
token,
|
||||||
|
timestamp: timestampValue,
|
||||||
|
nonce,
|
||||||
|
encrypted,
|
||||||
|
signature: sample.signature,
|
||||||
|
});
|
||||||
|
const decrypted = decryptWecomMessage({
|
||||||
|
encrypted,
|
||||||
|
encodingAesKey,
|
||||||
|
receiveId,
|
||||||
|
});
|
||||||
|
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
|
||||||
|
return {
|
||||||
|
id: "synthetic_callback_crypto",
|
||||||
|
status: signatureOk ? "OK" : "BLOCKED",
|
||||||
|
signatureOk,
|
||||||
|
decryptOk: Boolean(decrypted.plainXml),
|
||||||
|
summary: {
|
||||||
|
eventType: summary.eventType,
|
||||||
|
callbackKind: summary.callbackKind,
|
||||||
|
approvalType: summary.approvalType,
|
||||||
|
spStatus: summary.spStatus,
|
||||||
|
statusLabel: summary.statusLabel,
|
||||||
|
spNoPrefix: summary.spNoPrefix,
|
||||||
|
templateIdPrefix: summary.templateIdPrefix,
|
||||||
|
idempotencyKeyPrefix: maskWecomIdentifier(summary.idempotencyKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id: "synthetic_callback_crypto",
|
||||||
|
status: "BLOCKED",
|
||||||
|
reason: error?.code || "synthetic_callback_failed",
|
||||||
|
message: error instanceof WecomCallbackError ? error.message : "synthetic callback crypto failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUrlVerificationCheck({ config, callbackUrl, fetchImpl = fetch, timeoutMs = 10000 }) {
|
||||||
|
if (!configured(callbackUrl)) {
|
||||||
|
return {
|
||||||
|
id: "url_verification",
|
||||||
|
status: "NEEDS_CONFIG",
|
||||||
|
missing: ["WECOM_APPROVAL_CALLBACK_URL"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const configCheck = validateCallbackConfig({
|
||||||
|
wecomCorpId: config.wecomCorpId,
|
||||||
|
wecomCallbackToken: config.wecomCallbackToken,
|
||||||
|
wecomCallbackEncodingAesKey: config.wecomCallbackEncodingAesKey,
|
||||||
|
});
|
||||||
|
if (!configCheck.ok) {
|
||||||
|
return {
|
||||||
|
id: "url_verification",
|
||||||
|
status: "NEEDS_CONFIG",
|
||||||
|
missing: configCheck.missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sample = buildUrlVerificationSample({
|
||||||
|
token: config.wecomCallbackToken,
|
||||||
|
encodingAesKey: config.wecomCallbackEncodingAesKey,
|
||||||
|
receiveId: config.wecomCorpId,
|
||||||
|
timestamp: String(Math.floor(Date.now() / 1000)),
|
||||||
|
nonce: "wcbk1-live-url",
|
||||||
|
plainText: "wcbk1-url-verify-ok",
|
||||||
|
});
|
||||||
|
const url = new URL(callbackUrl);
|
||||||
|
url.searchParams.set("msg_signature", sample.signature);
|
||||||
|
url.searchParams.set("timestamp", sample.timestamp);
|
||||||
|
url.searchParams.set("nonce", sample.nonce);
|
||||||
|
url.searchParams.set("echostr", sample.encrypted);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const response = await fetchImpl(url, { method: "GET", signal: controller.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
const body = await response.text();
|
||||||
|
const matched = response.ok && body.trim() === sample.plainText;
|
||||||
|
return {
|
||||||
|
id: "url_verification",
|
||||||
|
status: matched ? "OK" : "BLOCKED",
|
||||||
|
httpStatus: response.status,
|
||||||
|
callbackUrlPreview: maskHostLike(callbackUrl),
|
||||||
|
matched,
|
||||||
|
reason: matched ? null : "callback_url_verify_failed",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id: "url_verification",
|
||||||
|
status: "BLOCKED",
|
||||||
|
callbackUrlPreview: maskHostLike(callbackUrl),
|
||||||
|
reason: error?.name === "AbortError" ? "callback_url_timeout" : "callback_url_network_error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildEvidenceCheck(config = {}) {
|
||||||
|
const events = await readCallbackEvidence({ evidenceFile: config.wecomApprovalCallbackEvidenceFile });
|
||||||
|
const approvalEvents = events.filter((item) => item.callbackKind === "approval_status_change");
|
||||||
|
const lastEvent = approvalEvents[approvalEvents.length - 1] || null;
|
||||||
|
return {
|
||||||
|
id: "callback_event_evidence",
|
||||||
|
status: approvalEvents.length ? "OK" : "PENDING",
|
||||||
|
eventCount: approvalEvents.length,
|
||||||
|
lastEvent: lastEvent
|
||||||
|
? {
|
||||||
|
eventType: lastEvent.eventType,
|
||||||
|
approvalType: lastEvent.approvalType,
|
||||||
|
spStatus: lastEvent.spStatus,
|
||||||
|
statusLabel: lastEvent.statusLabel,
|
||||||
|
spNoPrefix: lastEvent.spNoPrefix,
|
||||||
|
templateIdPrefix: lastEvent.templateIdPrefix,
|
||||||
|
recordedAt: lastEvent.recordedAt,
|
||||||
|
idempotencyKeyPrefix: maskWecomIdentifier(lastEvent.idempotencyKey),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateConclusion({ configCheck, syntheticCheck, urlCheck, evidenceCheck, live }) {
|
||||||
|
if (syntheticCheck.status !== "OK") {
|
||||||
|
return {
|
||||||
|
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||||
|
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||||
|
mainBlockers: [{ id: syntheticCheck.id, reason: syntheticCheck.reason || "synthetic_callback_failed" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (configCheck.status !== "OK") {
|
||||||
|
return {
|
||||||
|
status: "PARTIAL_CALLBACK_NEEDS_CONFIG",
|
||||||
|
conclusion: "PARTIAL_CALLBACK_NEEDS_CONFIG",
|
||||||
|
configGaps: configCheck.missing.map((name) => ({ name, status: "NEEDS_CONFIG" })),
|
||||||
|
mainBlockers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (live && urlCheck?.status === "BLOCKED") {
|
||||||
|
return {
|
||||||
|
status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||||
|
conclusion: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG",
|
||||||
|
mainBlockers: [{ id: urlCheck.id, reason: urlCheck.reason || "callback_url_verify_failed" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (evidenceCheck?.status === "OK") {
|
||||||
|
return {
|
||||||
|
status: "PASS_CALLBACK_EVENT_CONFIRMED",
|
||||||
|
conclusion: "PASS_CALLBACK_EVENT_CONFIRMED",
|
||||||
|
mainBlockers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (live && urlCheck?.status === "OK") {
|
||||||
|
return {
|
||||||
|
status: "PASS_CALLBACK_URL_VERIFIED",
|
||||||
|
conclusion: "PASS_CALLBACK_URL_VERIFIED",
|
||||||
|
mainBlockers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "PASS_CALLBACK_SYNTHETIC_READY",
|
||||||
|
conclusion: "PASS_CALLBACK_SYNTHETIC_READY",
|
||||||
|
mainBlockers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCallbackPoc(options = {}, deps = {}) {
|
||||||
|
const config = deps.config || loadCallbackConfig(deps.env);
|
||||||
|
const callbackUrl = options.callbackUrl || config.wecomApprovalCallbackUrl || "";
|
||||||
|
const syntheticCheck = runSyntheticCheck(config);
|
||||||
|
const configCheck = buildCallbackConfigCheck(config, callbackUrl);
|
||||||
|
const urlCheck = options.live
|
||||||
|
? await runUrlVerificationCheck({
|
||||||
|
config,
|
||||||
|
callbackUrl,
|
||||||
|
fetchImpl: deps.fetchImpl || fetch,
|
||||||
|
timeoutMs: config.wecomRequestTimeoutMs || 10000,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
id: "url_verification",
|
||||||
|
status: configCheck.status === "OK" ? "PENDING" : "NEEDS_CONFIG",
|
||||||
|
};
|
||||||
|
const evidenceCheck = await buildEvidenceCheck(config);
|
||||||
|
const conclusion = evaluateConclusion({
|
||||||
|
configCheck,
|
||||||
|
syntheticCheck,
|
||||||
|
urlCheck,
|
||||||
|
evidenceCheck,
|
||||||
|
live: Boolean(options.live),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: conclusion.status,
|
||||||
|
conclusion: conclusion.conclusion,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
scope: {
|
||||||
|
targets: ["leave", "missed_punch"],
|
||||||
|
live: Boolean(options.live),
|
||||||
|
writeBehavior: "read_only_callback_poc",
|
||||||
|
callbackUrlPreview: configured(callbackUrl) ? maskHostLike(callbackUrl) : null,
|
||||||
|
},
|
||||||
|
privacy: "redacted_no_token_no_secret_no_raw_xml_no_ciphertext_no_full_sp_no_no_full_userid_no_raw_payload",
|
||||||
|
checks: {
|
||||||
|
config: configCheck,
|
||||||
|
synthetic: syntheticCheck,
|
||||||
|
urlVerification: urlCheck,
|
||||||
|
eventEvidence: evidenceCheck,
|
||||||
|
},
|
||||||
|
configGaps: conclusion.configGaps || [],
|
||||||
|
mainBlockers: conclusion.mainBlockers || [],
|
||||||
|
nextManualAction: conclusion.status === "PASS_CALLBACK_URL_VERIFIED"
|
||||||
|
? "请由 SMOKE101 在企业微信处理一条现有 OA-POC 测试审批单,然后复跑 live runner。"
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let options;
|
||||||
|
try {
|
||||||
|
options = parseArgs();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`${error.message}\n${usage()}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.help) {
|
||||||
|
process.stdout.write(`${usage()}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await executeCallbackPoc(options);
|
||||||
|
const reportPath = writeReport(options.reportDir, report);
|
||||||
|
report.reportPath = reportPath;
|
||||||
|
if (options.format === "json") {
|
||||||
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(`[wecom-approval-callback-poc] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||||
|
process.stdout.write(`[wecom-approval-callback-poc] report=${reportPath}\n`);
|
||||||
|
process.stdout.write(`[wecom-approval-callback-poc] privacy=${report.privacy}\n`);
|
||||||
|
if (report.configGaps.length) {
|
||||||
|
process.stdout.write(`[wecom-approval-callback-poc] configGaps=${JSON.stringify(report.configGaps)}\n`);
|
||||||
|
}
|
||||||
|
if (report.mainBlockers.length) {
|
||||||
|
process.stdout.write(`[wecom-approval-callback-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (report.status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`[wecom-approval-callback-poc][FAIL] ${error?.message || error}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildCallbackConfigCheck,
|
||||||
|
buildEvidenceCheck,
|
||||||
|
evaluateConclusion,
|
||||||
|
executeCallbackPoc,
|
||||||
|
loadCallbackConfig,
|
||||||
|
parseArgs,
|
||||||
|
runSyntheticCheck,
|
||||||
|
runUrlVerificationCheck,
|
||||||
|
writeReport,
|
||||||
|
};
|
||||||
@@ -296,7 +296,7 @@ function buildApprovalConfigChecks(env = process.env) {
|
|||||||
{
|
{
|
||||||
id: "approval_callback_url",
|
id: "approval_callback_url",
|
||||||
label: "真实回调 URL",
|
label: "真实回调 URL",
|
||||||
env: ["WECOM_APPROVAL_CALLBACK_URL", "WECOM_CALLBACK_URL"],
|
env: ["WECOM_APPROVAL_CALLBACK_URL"],
|
||||||
mask: maskHostLike,
|
mask: maskHostLike,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -307,7 +307,7 @@ function buildApprovalConfigChecks(env = process.env) {
|
|||||||
{
|
{
|
||||||
id: "callback_encoding_aes_key",
|
id: "callback_encoding_aes_key",
|
||||||
label: "回调 EncodingAESKey",
|
label: "回调 EncodingAESKey",
|
||||||
env: ["WECOM_CALLBACK_ENCODING_AES_KEY", "WECOM_CALLBACK_AES_KEY"],
|
env: ["WECOM_CALLBACK_ENCODING_AES_KEY"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
const crypto = require("node:crypto");
|
||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const { XMLParser, XMLBuilder } = require("fast-xml-parser");
|
||||||
|
|
||||||
|
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../../artifacts/qa-flow");
|
||||||
|
const DEFAULT_EVIDENCE_FILE = path.join(
|
||||||
|
DEFAULT_REPORT_ROOT,
|
||||||
|
"wecom-approval-callback-events",
|
||||||
|
"events.jsonl",
|
||||||
|
);
|
||||||
|
const CALLBACK_EVENT_TYPES = new Set(["open_approval_change", "sys_approval_change"]);
|
||||||
|
|
||||||
|
class WecomCallbackError extends Error {
|
||||||
|
constructor(code, message, details = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = "WecomCallbackError";
|
||||||
|
this.code = code;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlParser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
parseTagValue: false,
|
||||||
|
parseAttributeValue: false,
|
||||||
|
trimValues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const xmlBuilder = new XMLBuilder({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
suppressEmptyNode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function toSafeString(value, maxLength = 500) {
|
||||||
|
if (value == null) return "";
|
||||||
|
return String(value).trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeErrorMessage(value) {
|
||||||
|
return toSafeString(value, 500)
|
||||||
|
.replace(/(msg_signature=)[^&\s]+/gi, "$1***")
|
||||||
|
.replace(/(echostr=)[^&\s]+/gi, "$1***")
|
||||||
|
.replace(/[A-Za-z0-9+/=_-]{32,}/g, "***")
|
||||||
|
.slice(0, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskWecomIdentifier(value) {
|
||||||
|
const raw = toSafeString(value, 160);
|
||||||
|
if (!raw) return null;
|
||||||
|
if (raw.length <= 6) return `${raw.slice(0, 2)}***`;
|
||||||
|
return `${raw.slice(0, Math.min(8, raw.length))}***`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEncodingAesKey(encodingAesKey) {
|
||||||
|
const raw = toSafeString(encodingAesKey, 80);
|
||||||
|
if (!raw) {
|
||||||
|
throw new WecomCallbackError("missing_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY is required");
|
||||||
|
}
|
||||||
|
if (raw.length !== 43) {
|
||||||
|
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY must be 43 characters");
|
||||||
|
}
|
||||||
|
const key = Buffer.from(`${raw}=`, "base64");
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new WecomCallbackError("invalid_encoding_aes_key", "WECOM_CALLBACK_ENCODING_AES_KEY decoded length is invalid");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWecomSignature({ token, timestamp, nonce, encrypted }) {
|
||||||
|
const parts = [token, timestamp, nonce, encrypted].map((item) => toSafeString(item, 4096));
|
||||||
|
if (parts.some((item) => !item)) {
|
||||||
|
throw new WecomCallbackError("missing_signature_input", "Token, timestamp, nonce and encrypted payload are required");
|
||||||
|
}
|
||||||
|
return crypto.createHash("sha1").update(parts.sort().join("")).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafeEqualHex(left, right) {
|
||||||
|
const a = Buffer.from(toSafeString(left, 80), "utf8");
|
||||||
|
const b = Buffer.from(toSafeString(right, 80), "utf8");
|
||||||
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyWecomSignature({ token, timestamp, nonce, encrypted, signature }) {
|
||||||
|
const expected = createWecomSignature({ token, timestamp, nonce, encrypted });
|
||||||
|
return timingSafeEqualHex(expected, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWecomSignature(input) {
|
||||||
|
if (!verifyWecomSignature(input)) {
|
||||||
|
throw new WecomCallbackError("invalid_msg_signature", "WeCom callback signature mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkcs7Pad(buffer, blockSize = 32) {
|
||||||
|
const padding = blockSize - (buffer.length % blockSize || blockSize);
|
||||||
|
const padLength = padding === 0 ? blockSize : padding;
|
||||||
|
return Buffer.concat([buffer, Buffer.alloc(padLength, padLength)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pkcs7Unpad(buffer) {
|
||||||
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||||
|
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted payload is empty");
|
||||||
|
}
|
||||||
|
const padLength = buffer[buffer.length - 1];
|
||||||
|
if (padLength < 1 || padLength > 32 || padLength > buffer.length) {
|
||||||
|
throw new WecomCallbackError("invalid_decrypted_padding", "Invalid WeCom callback padding");
|
||||||
|
}
|
||||||
|
return buffer.subarray(0, buffer.length - padLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptWecomMessage({ encrypted, encodingAesKey, receiveId }) {
|
||||||
|
const safeEncrypted = toSafeString(encrypted, 8192);
|
||||||
|
if (!safeEncrypted) {
|
||||||
|
throw new WecomCallbackError("missing_encrypted_payload", "Encrypted callback payload is required");
|
||||||
|
}
|
||||||
|
const key = normalizeEncodingAesKey(encodingAesKey);
|
||||||
|
let encryptedBuffer;
|
||||||
|
try {
|
||||||
|
encryptedBuffer = Buffer.from(safeEncrypted, "base64");
|
||||||
|
} catch (error) {
|
||||||
|
throw new WecomCallbackError("invalid_encrypted_payload", "Encrypted callback payload is not valid base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encryptedBuffer), decipher.final()]);
|
||||||
|
const plain = pkcs7Unpad(decrypted);
|
||||||
|
if (plain.length < 20) {
|
||||||
|
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback payload is too short");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageLength = plain.readUInt32BE(16);
|
||||||
|
const messageStart = 20;
|
||||||
|
const messageEnd = messageStart + messageLength;
|
||||||
|
if (messageLength < 0 || messageEnd > plain.length) {
|
||||||
|
throw new WecomCallbackError("invalid_decrypted_payload", "Decrypted callback XML length is invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainXml = plain.subarray(messageStart, messageEnd).toString("utf8");
|
||||||
|
const decryptedReceiveId = plain.subarray(messageEnd).toString("utf8");
|
||||||
|
const expectedReceiveId = toSafeString(receiveId, 160);
|
||||||
|
if (expectedReceiveId && decryptedReceiveId !== expectedReceiveId) {
|
||||||
|
throw new WecomCallbackError("receive_id_mismatch", "WeCom callback receiveId mismatch", {
|
||||||
|
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plainXml,
|
||||||
|
receiveId: decryptedReceiveId,
|
||||||
|
receiveIdPrefix: maskWecomIdentifier(decryptedReceiveId),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WecomCallbackError) throw error;
|
||||||
|
throw new WecomCallbackError("decrypt_failed", sanitizeErrorMessage(error?.message || "WeCom callback decrypt failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encryptWecomMessage({ plainXml, encodingAesKey, receiveId, randomBytes }) {
|
||||||
|
const key = normalizeEncodingAesKey(encodingAesKey);
|
||||||
|
const xmlBuffer = Buffer.from(toSafeString(plainXml, 20000), "utf8");
|
||||||
|
const lengthBuffer = Buffer.alloc(4);
|
||||||
|
lengthBuffer.writeUInt32BE(xmlBuffer.length, 0);
|
||||||
|
const random = randomBytes ? Buffer.from(randomBytes).subarray(0, 16) : crypto.randomBytes(16);
|
||||||
|
if (random.length !== 16) {
|
||||||
|
throw new WecomCallbackError("invalid_random_bytes", "WeCom encrypted message needs 16 random bytes");
|
||||||
|
}
|
||||||
|
const plain = pkcs7Pad(Buffer.concat([
|
||||||
|
random,
|
||||||
|
lengthBuffer,
|
||||||
|
xmlBuffer,
|
||||||
|
Buffer.from(toSafeString(receiveId, 160), "utf8"),
|
||||||
|
]));
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||||
|
cipher.setAutoPadding(false);
|
||||||
|
return Buffer.concat([cipher.update(plain), cipher.final()]).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWecomXml(xml) {
|
||||||
|
const safeXml = toSafeString(xml, 20000);
|
||||||
|
if (!safeXml) {
|
||||||
|
throw new WecomCallbackError("missing_xml", "WeCom callback XML is empty");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = xmlParser.parse(safeXml);
|
||||||
|
const root = parsed?.xml && typeof parsed.xml === "object" ? parsed.xml : parsed;
|
||||||
|
if (!root || typeof root !== "object") {
|
||||||
|
throw new Error("missing xml root");
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
} catch (error) {
|
||||||
|
throw new WecomCallbackError("invalid_xml", sanitizeErrorMessage(error?.message || "WeCom callback XML parse failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEncryptedXml(encrypted) {
|
||||||
|
return xmlBuilder.build({ xml: { Encrypt: toSafeString(encrypted, 8192) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractEncryptedFromXml(xml) {
|
||||||
|
const root = parseWecomXml(xml);
|
||||||
|
const encrypted = toSafeString(root.Encrypt, 8192);
|
||||||
|
if (!encrypted) {
|
||||||
|
throw new WecomCallbackError("missing_encrypt_node", "WeCom callback XML missing Encrypt node");
|
||||||
|
}
|
||||||
|
return encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickText(source, names) {
|
||||||
|
if (!source || typeof source !== "object") return "";
|
||||||
|
for (const name of names) {
|
||||||
|
const value = source[name];
|
||||||
|
if (value == null) continue;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const nested = pickText(value, ["UserId", "userid", "Userid", "Name", "name", "#text"]);
|
||||||
|
if (nested) return nested;
|
||||||
|
} else {
|
||||||
|
const text = toSafeString(value, 300);
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferApprovalType({ spName, templateId }) {
|
||||||
|
const text = `${spName || ""} ${templateId || ""}`;
|
||||||
|
if (/请假|leave/i.test(text)) return "leave";
|
||||||
|
if (/补卡|missed|punch|打卡/i.test(text)) return "missed_punch";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
const text = toSafeString(status, 40);
|
||||||
|
const labels = {
|
||||||
|
"1": "审批中",
|
||||||
|
"2": "已通过",
|
||||||
|
"3": "已驳回",
|
||||||
|
"4": "已撤销",
|
||||||
|
"6": "通过后撤销",
|
||||||
|
"7": "已删除",
|
||||||
|
"10": "已支付",
|
||||||
|
};
|
||||||
|
return labels[text] || text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdempotencyKey(parts) {
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(parts.map((item) => toSafeString(item, 300)).join("|"))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApprovalCallbackEvent(plainXml) {
|
||||||
|
const root = parseWecomXml(plainXml);
|
||||||
|
const eventType = pickText(root, ["Event", "event"]);
|
||||||
|
const msgType = pickText(root, ["MsgType", "msgtype"]);
|
||||||
|
const approvalInfo = root.ApprovalInfo && typeof root.ApprovalInfo === "object"
|
||||||
|
? root.ApprovalInfo
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const spNo = pickText(approvalInfo, ["SpNo", "sp_no", "SpNO", "Spno"]);
|
||||||
|
const templateId = pickText(approvalInfo, ["TemplateId", "TemplateID", "template_id"]);
|
||||||
|
const spName = pickText(approvalInfo, ["SpName", "sp_name", "TemplateName"]);
|
||||||
|
const spStatus = pickText(approvalInfo, ["SpStatus", "sp_status", "Status"]);
|
||||||
|
const createTime = pickText(root, ["CreateTime", "create_time"]);
|
||||||
|
const applyTime = pickText(approvalInfo, ["ApplyTime", "apply_time"]);
|
||||||
|
const agentId = pickText(root, ["AgentID", "AgentId", "agentid"]);
|
||||||
|
const approvalType = inferApprovalType({ spName, templateId });
|
||||||
|
|
||||||
|
const idempotencyKey = buildIdempotencyKey([
|
||||||
|
eventType,
|
||||||
|
spNo,
|
||||||
|
templateId,
|
||||||
|
spStatus,
|
||||||
|
createTime,
|
||||||
|
applyTime,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
eventType: eventType || "unknown",
|
||||||
|
msgType: msgType || "unknown",
|
||||||
|
approvalType,
|
||||||
|
spStatus: spStatus || null,
|
||||||
|
statusLabel: statusLabel(spStatus),
|
||||||
|
spNoPrefix: maskWecomIdentifier(spNo),
|
||||||
|
templateIdPrefix: maskWecomIdentifier(templateId),
|
||||||
|
createTime: createTime || null,
|
||||||
|
applyTime: applyTime || null,
|
||||||
|
agentIdPrefix: maskWecomIdentifier(agentId),
|
||||||
|
callbackKind: CALLBACK_EVENT_TYPES.has(eventType) ? "approval_status_change" : "unknown_event",
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCallbackConfig(config) {
|
||||||
|
const missing = [];
|
||||||
|
if (!toSafeString(config?.wecomCorpId, 160)) missing.push("WECOM_CORP_ID");
|
||||||
|
if (!toSafeString(config?.wecomCallbackToken, 300)) missing.push("WECOM_CALLBACK_TOKEN");
|
||||||
|
if (!toSafeString(config?.wecomCallbackEncodingAesKey, 80)) missing.push("WECOM_CALLBACK_ENCODING_AES_KEY");
|
||||||
|
return {
|
||||||
|
ok: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrlVerificationSample({ token, encodingAesKey, receiveId, timestamp, nonce, plainText }) {
|
||||||
|
const encrypted = encryptWecomMessage({
|
||||||
|
plainXml: plainText,
|
||||||
|
encodingAesKey,
|
||||||
|
receiveId,
|
||||||
|
randomBytes: Buffer.from("0123456789abcdef"),
|
||||||
|
});
|
||||||
|
const signature = createWecomSignature({ token, timestamp, nonce, encrypted });
|
||||||
|
return {
|
||||||
|
encrypted,
|
||||||
|
signature,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
plainText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEncryptedApprovalEventSample({ token, encodingAesKey, receiveId, timestamp, nonce, spNo, templateId }) {
|
||||||
|
const plainXml = xmlBuilder.build({
|
||||||
|
xml: {
|
||||||
|
ToUserName: receiveId,
|
||||||
|
FromUserName: "sys",
|
||||||
|
CreateTime: timestamp,
|
||||||
|
MsgType: "event",
|
||||||
|
Event: "open_approval_change",
|
||||||
|
AgentID: "1000002",
|
||||||
|
ApprovalInfo: {
|
||||||
|
SpNo: spNo,
|
||||||
|
SpName: "OA-POC 请假",
|
||||||
|
SpStatus: "2",
|
||||||
|
TemplateId: templateId,
|
||||||
|
ApplyTime: timestamp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const encrypted = encryptWecomMessage({
|
||||||
|
plainXml,
|
||||||
|
encodingAesKey,
|
||||||
|
receiveId,
|
||||||
|
randomBytes: Buffer.from("fedcba9876543210"),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
plainXml,
|
||||||
|
encryptedXml: buildEncryptedXml(encrypted),
|
||||||
|
encrypted,
|
||||||
|
signature: createWecomSignature({ token, timestamp, nonce, encrypted }),
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordCallbackEvidence(summary, options = {}) {
|
||||||
|
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
|
||||||
|
await fs.mkdir(path.dirname(evidenceFile), { recursive: true });
|
||||||
|
const idempotencyKey = toSafeString(summary?.idempotencyKey, 80);
|
||||||
|
let existingKeys = new Set();
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(evidenceFile, "utf8");
|
||||||
|
existingKeys = new Set(content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line).idempotencyKey;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean));
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
if (idempotencyKey && existingKeys.has(idempotencyKey)) {
|
||||||
|
return {
|
||||||
|
recorded: false,
|
||||||
|
duplicate: true,
|
||||||
|
evidenceFile,
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
eventType: summary.eventType,
|
||||||
|
msgType: summary.msgType,
|
||||||
|
callbackKind: summary.callbackKind,
|
||||||
|
approvalType: summary.approvalType,
|
||||||
|
spStatus: summary.spStatus,
|
||||||
|
statusLabel: summary.statusLabel,
|
||||||
|
spNoPrefix: summary.spNoPrefix,
|
||||||
|
templateIdPrefix: summary.templateIdPrefix,
|
||||||
|
createTime: summary.createTime,
|
||||||
|
applyTime: summary.applyTime,
|
||||||
|
agentIdPrefix: summary.agentIdPrefix,
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
await fs.appendFile(evidenceFile, `${JSON.stringify(event)}\n`, "utf8");
|
||||||
|
return {
|
||||||
|
recorded: true,
|
||||||
|
duplicate: false,
|
||||||
|
evidenceFile,
|
||||||
|
idempotencyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCallbackEvidence(options = {}) {
|
||||||
|
const evidenceFile = options.evidenceFile || process.env.WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE || DEFAULT_EVIDENCE_FILE;
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(evidenceFile, "utf8");
|
||||||
|
return content
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === "ENOENT") return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CALLBACK_EVENT_TYPES,
|
||||||
|
DEFAULT_EVIDENCE_FILE,
|
||||||
|
WecomCallbackError,
|
||||||
|
assertWecomSignature,
|
||||||
|
buildEncryptedApprovalEventSample,
|
||||||
|
buildEncryptedXml,
|
||||||
|
buildUrlVerificationSample,
|
||||||
|
createWecomSignature,
|
||||||
|
decryptWecomMessage,
|
||||||
|
encryptWecomMessage,
|
||||||
|
extractEncryptedFromXml,
|
||||||
|
maskWecomIdentifier,
|
||||||
|
normalizeApprovalCallbackEvent,
|
||||||
|
parseWecomXml,
|
||||||
|
readCallbackEvidence,
|
||||||
|
recordCallbackEvidence,
|
||||||
|
sanitizeErrorMessage,
|
||||||
|
validateCallbackConfig,
|
||||||
|
verifyWecomSignature,
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const Fastify = require("fastify");
|
||||||
|
|
||||||
|
const wecomApprovalCallbackRoutes = require("../../../src/routes/wecomApprovalCallback");
|
||||||
|
const {
|
||||||
|
buildEncryptedApprovalEventSample,
|
||||||
|
buildUrlVerificationSample,
|
||||||
|
} = require("../../../src/services/integrations/wecomCallbackCrypto");
|
||||||
|
|
||||||
|
const TOKEN = "callback-token";
|
||||||
|
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||||
|
const CORP_ID = "ww-callback-corp";
|
||||||
|
|
||||||
|
async function buildApp({ config = {}, evidenceStore } = {}) {
|
||||||
|
const app = Fastify();
|
||||||
|
app.decorate("config", {
|
||||||
|
wecomCorpId: CORP_ID,
|
||||||
|
wecomCallbackToken: TOKEN,
|
||||||
|
wecomCallbackEncodingAesKey: AES_KEY,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
await app.register(wecomApprovalCallbackRoutes, { prefix: "/api", evidenceStore });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlQuery(sample) {
|
||||||
|
return `msg_signature=${encodeURIComponent(sample.signature)}×tamp=${encodeURIComponent(sample.timestamp)}&nonce=${encodeURIComponent(sample.nonce)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("wecomApprovalCallback routes", () => {
|
||||||
|
it("verifies callback URL and returns decrypted echostr", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const sample = buildUrlVerificationSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
plainText: "wcbk1-url-verify-ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toBe("wcbk1-url-verify-ok");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid URL verification signatures", async () => {
|
||||||
|
const app = await buildApp();
|
||||||
|
const sample = buildUrlVerificationSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
plainText: "wcbk1-url-verify-ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/wecom/approval/callback?msg_signature=bad×tamp=${sample.timestamp}&nonce=${sample.nonce}&echostr=${encodeURIComponent(sample.encrypted)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.body).toBe("invalid signature");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts approval callback POST and records only normalized evidence", async () => {
|
||||||
|
const evidenceStore = {
|
||||||
|
record: vi.fn().mockResolvedValue({ recorded: true, duplicate: false }),
|
||||||
|
};
|
||||||
|
const app = await buildApp({ evidenceStore });
|
||||||
|
const sample = buildEncryptedApprovalEventSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
spNo: "SPFULLIDENTIFIERSHOULDNOTLEAK",
|
||||||
|
templateId: "TPLFULLIDENTIFIERSHOULDNOTLEAK",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/wecom/approval/callback?${urlQuery(sample)}`,
|
||||||
|
headers: { "content-type": "text/xml" },
|
||||||
|
payload: sample.encryptedXml,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.body).toBe("success");
|
||||||
|
expect(evidenceStore.record).toHaveBeenCalledTimes(1);
|
||||||
|
const summary = evidenceStore.record.mock.calls[0][0];
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
eventType: "open_approval_change",
|
||||||
|
callbackKind: "approval_status_change",
|
||||||
|
approvalType: "leave",
|
||||||
|
statusLabel: "已通过",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(summary)).not.toContain("SPFULLIDENTIFIERSHOULDNOTLEAK");
|
||||||
|
expect(JSON.stringify(summary)).not.toContain("TPLFULLIDENTIFIERSHOULDNOTLEAK");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports missing callback config without treating it as pass", async () => {
|
||||||
|
const app = await buildApp({ config: { wecomCallbackToken: "" } });
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/wecom/approval/callback?msg_signature=x×tamp=1&nonce=2&echostr=3",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.body).toBe("callback config missing");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,24 @@ function validFixtureRow(overrides = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validCallbackRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
ID: "WCBK1-0",
|
||||||
|
Title: "合法企微审批回调只读合同",
|
||||||
|
Description: "WCBK1 回调只读 POC,路线:WCBK1 -> WACP3 -> WADM1 -> wecom_attendance_approval_mirror_v1;范围只验证企微审批回调。",
|
||||||
|
Acceptance: "固定 WECOM_APPROVAL_CALLBACK_URL、WECOM_CALLBACK_TOKEN、WECOM_CALLBACK_ENCODING_AES_KEY;GET /api/wecom/approval/callback 做 URL 验证;POST /api/wecom/approval/callback 接收事件;使用 msg_signature 验签和 EncodingAESKey 解密;只读、幂等,不写 OA 业务镜像,不写企微数据;不使用通讯录 Secret,不尝试其他 Secret,不允许运行时猜 Secret;缺配置记录 NEEDS_CONFIG,外部阻断记录 SKIP/BLOCKED_BY_EXTERNAL_CONFIG。",
|
||||||
|
Test_Method: "受影响流程:WAPC2FLIVE 样件 -> WCBK1 回调只读 POC -> WACP3 补偿拉取;验证步骤:node scripts/qa/check-process-contract.mjs --issue callback.csv --profile wecom-callback --json。",
|
||||||
|
Tools: "node|vitest",
|
||||||
|
Dev_Status: "TODO",
|
||||||
|
Review1_Status: "TODO",
|
||||||
|
Regression_Status: "TODO",
|
||||||
|
Files: "server/src/routes/wecomApprovalCallback.js|server/src/services/integrations/wecomCallbackCrypto.js",
|
||||||
|
Dependencies: "[]",
|
||||||
|
Notes: "回滚路径:移除回调 URL 或新增路由;报告不得输出原始 XML、密文、完整 sp_no、完整 userid、完整 template_id、Token、EncodingAESKey 或原始 payload。",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function evaluate(rows, profile = "wecom-approval-fixture") {
|
function evaluate(rows, profile = "wecom-approval-fixture") {
|
||||||
return gate.evaluateIssueContract({
|
return gate.evaluateIssueContract({
|
||||||
csvText: buildCsv(rows),
|
csvText: buildCsv(rows),
|
||||||
@@ -72,6 +90,14 @@ describe("check-process-contract", () => {
|
|||||||
expect(result.checks.some((item) => item.name === "profile.wecom_approval_fixture")).toBe(true);
|
expect(result.checks.some((item) => item.name === "profile.wecom_approval_fixture")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes a valid WCBK1 callback contract", () => {
|
||||||
|
const result = evaluate([validCallbackRow()], "wecom-callback");
|
||||||
|
|
||||||
|
expect(result.status).toBe("PASS");
|
||||||
|
expect(errorCodes(result)).toEqual([]);
|
||||||
|
expect(result.checks.some((item) => item.name === "profile.wecom_callback")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("fails when Dependencies is not a JSON array", () => {
|
it("fails when Dependencies is not a JSON array", () => {
|
||||||
const result = evaluate([validFixtureRow({ Dependencies: "WAPC2F-0" })]);
|
const result = evaluate([validFixtureRow({ Dependencies: "WAPC2F-0" })]);
|
||||||
|
|
||||||
@@ -138,6 +164,10 @@ describe("check-process-contract", () => {
|
|||||||
issue: "a.csv",
|
issue: "a.csv",
|
||||||
profile: "wecom",
|
profile: "wecom",
|
||||||
});
|
});
|
||||||
|
expect(gate.parseArgs(["--issue", "a.csv", "--profile", "wecom-callback"])).toMatchObject({
|
||||||
|
issue: "a.csv",
|
||||||
|
profile: "wecom-callback",
|
||||||
|
});
|
||||||
expect(() => gate.parseArgs(["--issue", "a.csv", "--profile", "unknown"])).toThrow(/unsupported profile/);
|
expect(() => gate.parseArgs(["--issue", "a.csv", "--profile", "unknown"])).toThrow(/unsupported profile/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildCallbackConfigCheck,
|
||||||
|
executeCallbackPoc,
|
||||||
|
parseArgs,
|
||||||
|
runSyntheticCheck,
|
||||||
|
runUrlVerificationCheck,
|
||||||
|
} = require("../../../src/scripts/check-wecom-approval-callback-poc");
|
||||||
|
|
||||||
|
const TOKEN = "callback-token";
|
||||||
|
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||||
|
const CORP_ID = "ww-callback-corp";
|
||||||
|
const SECRET_VALUE = "secret-value-should-not-leak";
|
||||||
|
|
||||||
|
function baseConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
wecomCorpId: CORP_ID,
|
||||||
|
wecomApprovalCallbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
|
wecomCallbackToken: TOKEN,
|
||||||
|
wecomCallbackEncodingAesKey: AES_KEY,
|
||||||
|
wecomApprovalCallbackEvidenceFile: path.join("/tmp", `wcbk1-script-${Date.now()}-${Math.random()}.jsonl`),
|
||||||
|
wecomRequestTimeoutMs: 1000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(body, status = 200) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
text: async () => body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("check-wecom-approval-callback-poc", () => {
|
||||||
|
it("parses CLI options", () => {
|
||||||
|
const options = parseArgs([
|
||||||
|
"--live",
|
||||||
|
"--format=json",
|
||||||
|
"--callback-url=https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
|
"--report-dir=/tmp/wcbk1",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(options).toMatchObject({
|
||||||
|
live: true,
|
||||||
|
format: "json",
|
||||||
|
callbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
|
reportDir: "/tmp/wcbk1",
|
||||||
|
});
|
||||||
|
expect(() => parseArgs(["--format=xml"])).toThrow(/unsupported format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies missing callback config as NEEDS_CONFIG", () => {
|
||||||
|
const check = buildCallbackConfigCheck(baseConfig({ wecomCallbackToken: "", wecomApprovalCallbackUrl: "" }), "");
|
||||||
|
|
||||||
|
expect(check.status).toBe("NEEDS_CONFIG");
|
||||||
|
expect(check.missing).toEqual(expect.arrayContaining([
|
||||||
|
"WECOM_APPROVAL_CALLBACK_URL",
|
||||||
|
"WECOM_CALLBACK_TOKEN",
|
||||||
|
]));
|
||||||
|
expect(JSON.stringify(check)).not.toContain(SECRET_VALUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs synthetic crypto check without leaking raw identifiers", () => {
|
||||||
|
const check = runSyntheticCheck(baseConfig());
|
||||||
|
|
||||||
|
expect(check.status).toBe("OK");
|
||||||
|
expect(check.summary).toMatchObject({
|
||||||
|
eventType: "open_approval_change",
|
||||||
|
approvalType: "leave",
|
||||||
|
statusLabel: "已通过",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(check)).not.toContain("SPWCBK1SYNTHETIC000001");
|
||||||
|
expect(JSON.stringify(check)).not.toContain("TPLWCBK1SYNTHETIC000001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies live callback URL with generated echostr", async () => {
|
||||||
|
const fetchImpl = vi.fn(async (url) => {
|
||||||
|
expect(String(url)).toContain("msg_signature=");
|
||||||
|
expect(String(url)).not.toContain(TOKEN);
|
||||||
|
expect(String(url)).not.toContain(AES_KEY);
|
||||||
|
return textResponse("wcbk1-url-verify-ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
const check = await runUrlVerificationCheck({
|
||||||
|
config: baseConfig(),
|
||||||
|
callbackUrl: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(check).toMatchObject({
|
||||||
|
status: "OK",
|
||||||
|
matched: true,
|
||||||
|
callbackUrlPreview: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns PARTIAL_CALLBACK_NEEDS_CONFIG when real callback config is missing", async () => {
|
||||||
|
const report = await executeCallbackPoc({
|
||||||
|
live: false,
|
||||||
|
format: "json",
|
||||||
|
}, {
|
||||||
|
config: baseConfig({
|
||||||
|
wecomApprovalCallbackUrl: "",
|
||||||
|
wecomCallbackToken: "",
|
||||||
|
wecomCallbackEncodingAesKey: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.status).toBe("PARTIAL_CALLBACK_NEEDS_CONFIG");
|
||||||
|
expect(report.checks.synthetic.status).toBe("OK");
|
||||||
|
expect(report.configGaps.map((item) => item.name)).toEqual(expect.arrayContaining([
|
||||||
|
"WECOM_APPROVAL_CALLBACK_URL",
|
||||||
|
"WECOM_CALLBACK_TOKEN",
|
||||||
|
"WECOM_CALLBACK_ENCODING_AES_KEY",
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns PASS_CALLBACK_EVENT_CONFIRMED when sanitized event evidence exists", async () => {
|
||||||
|
const config = baseConfig();
|
||||||
|
await fs.mkdir(path.dirname(config.wecomApprovalCallbackEvidenceFile), { recursive: true });
|
||||||
|
await fs.writeFile(config.wecomApprovalCallbackEvidenceFile, `${JSON.stringify({
|
||||||
|
recordedAt: "2026-05-19T06:00:00.000Z",
|
||||||
|
eventType: "open_approval_change",
|
||||||
|
callbackKind: "approval_status_change",
|
||||||
|
approvalType: "leave",
|
||||||
|
spStatus: "2",
|
||||||
|
statusLabel: "已通过",
|
||||||
|
spNoPrefix: "SPFULLID***",
|
||||||
|
templateIdPrefix: "TPLFULLI***",
|
||||||
|
idempotencyKey: "idem",
|
||||||
|
})}\n`);
|
||||||
|
const fetchImpl = vi.fn(async () => textResponse("wcbk1-url-verify-ok"));
|
||||||
|
|
||||||
|
const report = await executeCallbackPoc({ live: true }, { config, fetchImpl });
|
||||||
|
|
||||||
|
expect(report.status).toBe("PASS_CALLBACK_EVENT_CONFIRMED");
|
||||||
|
expect(report.checks.eventEvidence.eventCount).toBe(1);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(TOKEN);
|
||||||
|
expect(JSON.stringify(report)).not.toContain(AES_KEY);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -267,7 +267,7 @@ describe("check-wecom-runtime-ready", () => {
|
|||||||
it("classifies missing approval and callback values as NEEDS_CONFIG", () => {
|
it("classifies missing approval and callback values as NEEDS_CONFIG", () => {
|
||||||
const checks = buildApprovalConfigChecks({
|
const checks = buildApprovalConfigChecks({
|
||||||
WECOM_APPROVAL_TEMPLATE_ID: "approval-template-id",
|
WECOM_APPROVAL_TEMPLATE_ID: "approval-template-id",
|
||||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
WECOM_APPROVAL_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(checks.find((item) => item.id === "approval_template_id")).toMatchObject({
|
expect(checks.find((item) => item.id === "approval_template_id")).toMatchObject({
|
||||||
@@ -276,7 +276,7 @@ describe("check-wecom-runtime-ready", () => {
|
|||||||
});
|
});
|
||||||
expect(checks.find((item) => item.id === "approval_callback_url")).toMatchObject({
|
expect(checks.find((item) => item.id === "approval_callback_url")).toMatchObject({
|
||||||
status: "CONFIGURED",
|
status: "CONFIGURED",
|
||||||
configuredEnv: "WECOM_CALLBACK_URL",
|
configuredEnv: "WECOM_APPROVAL_CALLBACK_URL",
|
||||||
valuePreview: "https://oa.hyinda.com/...",
|
valuePreview: "https://oa.hyinda.com/...",
|
||||||
});
|
});
|
||||||
expect(checks.find((item) => item.id === "approval_test_sp_no")).toMatchObject({
|
expect(checks.find((item) => item.id === "approval_test_sp_no")).toMatchObject({
|
||||||
@@ -301,9 +301,9 @@ describe("check-wecom-runtime-ready", () => {
|
|||||||
WECOM_RUNTIME_TEST_SEND: "1",
|
WECOM_RUNTIME_TEST_SEND: "1",
|
||||||
WECOM_APPROVAL_TEMPLATE_ID: "tpl",
|
WECOM_APPROVAL_TEMPLATE_ID: "tpl",
|
||||||
WECOM_APPROVAL_TEST_SP_NO: "sp",
|
WECOM_APPROVAL_TEST_SP_NO: "sp",
|
||||||
WECOM_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/callback",
|
WECOM_APPROVAL_CALLBACK_URL: "https://oa.hyinda.com/api/wecom/approval/callback",
|
||||||
WECOM_CALLBACK_TOKEN: SENSITIVE_CALLBACK_TOKEN,
|
WECOM_CALLBACK_TOKEN: SENSITIVE_CALLBACK_TOKEN,
|
||||||
WECOM_CALLBACK_AES_KEY: SENSITIVE_CALLBACK_AES_KEY,
|
WECOM_CALLBACK_ENCODING_AES_KEY: SENSITIVE_CALLBACK_AES_KEY,
|
||||||
},
|
},
|
||||||
oaRows: [boundEmployee()],
|
oaRows: [boundEmployee()],
|
||||||
fetchProfilesImpl: vi.fn(async () => ({
|
fetchProfilesImpl: vi.fn(async () => ({
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
const fs = require("node:fs/promises");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const {
|
||||||
|
WecomCallbackError,
|
||||||
|
buildEncryptedApprovalEventSample,
|
||||||
|
buildUrlVerificationSample,
|
||||||
|
createWecomSignature,
|
||||||
|
decryptWecomMessage,
|
||||||
|
extractEncryptedFromXml,
|
||||||
|
maskWecomIdentifier,
|
||||||
|
normalizeApprovalCallbackEvent,
|
||||||
|
readCallbackEvidence,
|
||||||
|
recordCallbackEvidence,
|
||||||
|
verifyWecomSignature,
|
||||||
|
} = require("../../../src/services/integrations/wecomCallbackCrypto");
|
||||||
|
|
||||||
|
const TOKEN = "callback-token";
|
||||||
|
const AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
|
||||||
|
const CORP_ID = "ww-callback-corp";
|
||||||
|
const SENSITIVE_SP_NO = "SPFULLIDENTIFIERSHOULDNOTLEAK0001";
|
||||||
|
const SENSITIVE_TEMPLATE_ID = "TPLFULLIDENTIFIERSHOULDNOTLEAK0001";
|
||||||
|
|
||||||
|
describe("wecomCallbackCrypto", () => {
|
||||||
|
it("creates and verifies WeCom callback signatures", () => {
|
||||||
|
const signature = createWecomSignature({
|
||||||
|
token: TOKEN,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
encrypted: "encrypted-payload",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(signature).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
expect(verifyWecomSignature({
|
||||||
|
token: TOKEN,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
encrypted: "encrypted-payload",
|
||||||
|
signature,
|
||||||
|
})).toBe(true);
|
||||||
|
expect(verifyWecomSignature({
|
||||||
|
token: TOKEN,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
encrypted: "other",
|
||||||
|
signature,
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts URL verification echostr samples", () => {
|
||||||
|
const sample = buildUrlVerificationSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
plainText: "wcbk1-url-verify-ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
const decrypted = decryptWecomMessage({
|
||||||
|
encrypted: sample.encrypted,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sample.signature).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
expect(decrypted.plainXml).toBe("wcbk1-url-verify-ok");
|
||||||
|
expect(decrypted.receiveId).toBe(CORP_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes approval callback events without leaking raw identifiers", () => {
|
||||||
|
const sample = buildEncryptedApprovalEventSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
spNo: SENSITIVE_SP_NO,
|
||||||
|
templateId: SENSITIVE_TEMPLATE_ID,
|
||||||
|
});
|
||||||
|
const encrypted = extractEncryptedFromXml(sample.encryptedXml);
|
||||||
|
const decrypted = decryptWecomMessage({
|
||||||
|
encrypted,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
});
|
||||||
|
const summary = normalizeApprovalCallbackEvent(decrypted.plainXml);
|
||||||
|
|
||||||
|
expect(summary).toMatchObject({
|
||||||
|
eventType: "open_approval_change",
|
||||||
|
msgType: "event",
|
||||||
|
callbackKind: "approval_status_change",
|
||||||
|
approvalType: "leave",
|
||||||
|
spStatus: "2",
|
||||||
|
statusLabel: "已通过",
|
||||||
|
spNoPrefix: maskWecomIdentifier(SENSITIVE_SP_NO),
|
||||||
|
templateIdPrefix: maskWecomIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_SP_NO);
|
||||||
|
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||||
|
expect(summary.idempotencyKey).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects mismatched receiveId", () => {
|
||||||
|
const sample = buildUrlVerificationSample({
|
||||||
|
token: TOKEN,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: CORP_ID,
|
||||||
|
timestamp: "1770000000",
|
||||||
|
nonce: "nonce",
|
||||||
|
plainText: "ok",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => decryptWecomMessage({
|
||||||
|
encrypted: sample.encrypted,
|
||||||
|
encodingAesKey: AES_KEY,
|
||||||
|
receiveId: "other-corp",
|
||||||
|
})).toThrow(WecomCallbackError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records callback evidence idempotently and only stores redacted fields", async () => {
|
||||||
|
const evidenceFile = path.join("/tmp", `wcbk1-evidence-${Date.now()}-${Math.random()}.jsonl`);
|
||||||
|
const summary = {
|
||||||
|
eventType: "open_approval_change",
|
||||||
|
msgType: "event",
|
||||||
|
callbackKind: "approval_status_change",
|
||||||
|
approvalType: "leave",
|
||||||
|
spStatus: "2",
|
||||||
|
statusLabel: "已通过",
|
||||||
|
spNoPrefix: maskWecomIdentifier(SENSITIVE_SP_NO),
|
||||||
|
templateIdPrefix: maskWecomIdentifier(SENSITIVE_TEMPLATE_ID),
|
||||||
|
createTime: "1770000000",
|
||||||
|
applyTime: "1770000000",
|
||||||
|
agentIdPrefix: "1000***",
|
||||||
|
idempotencyKey: "idem-key",
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = await recordCallbackEvidence(summary, { evidenceFile });
|
||||||
|
const second = await recordCallbackEvidence(summary, { evidenceFile });
|
||||||
|
const events = await readCallbackEvidence({ evidenceFile });
|
||||||
|
const raw = await fs.readFile(evidenceFile, "utf8");
|
||||||
|
|
||||||
|
expect(first.recorded).toBe(true);
|
||||||
|
expect(second.duplicate).toBe(true);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(raw).not.toContain(SENSITIVE_SP_NO);
|
||||||
|
expect(raw).not.toContain(SENSITIVE_TEMPLATE_ID);
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户