diff --git a/.agents/skills/hyb-oa-delivery/SKILL.md b/.agents/skills/hyb-oa-delivery/SKILL.md index be90fa18..37ce02d7 100644 --- a/.agents/skills/hyb-oa-delivery/SKILL.md +++ b/.agents/skills/hyb-oa-delivery/SKILL.md @@ -17,8 +17,8 @@ Read only what is relevant: - For approval workflow, roles, self-approval, notification, deeplink, reminder, or escalation work, read `docs/需求-统一审批中心规约.md`. - 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 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/WPROC2 fixed the engineering process gate; WEGOV1 is now the current route gate, so WCBK1 must be followed by WACP3 compensation pull, WREAD1 readability/misapproval check, WADM1 self-owned archive/mirror model design, and WARCH1 minimum history archive 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`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. This still does not clear compensation pull, mirror data model, emergency mode, writeback, or RuleOps. +- 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/WPROC2 fixed the engineering process gate; WEGOV1 is the route gate, and WACP3 has now implemented the compensation-pull runner but public live read is `PARTIAL_COMPENSATION_NEEDS_CONFIG` because the remote env lacks complete leave/missed-punch template IDs. WREAD1 readability/misapproval check, WADM1 self-owned archive/mirror model design, and WARCH1 minimum history archive still remain 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`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3 has added and deployed `npm run check:wecom-approval-compensation-poc` and `POST /cgi-bin/oa/getapprovalinfo` read support; public report `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` is `PARTIAL_COMPENSATION_NEEDS_CONFIG` because `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` and `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID` are not configured, so it did not pull details or compare callback evidence. This still does not clear mirror data model, emergency mode, writeback, or RuleOps. - WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. Do not start deep mirror until WACP3 compensation pull POC, WREAD1 readability/misapproval gate, WADM1 self-owned archive/mirror data model, and WARCH1 minimum archive implementation have their own contracts and evidence. - 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`. @@ -108,8 +108,8 @@ Final responses should include: - R6 scope defaults to reimbursement, leave, procurement, approval, Enterprise WeChat entry/notification, finance payment/export. - Do not expand to budget, ERP, AI, full travel, or complex BPM during R6; record those as follow-up Issues. - Enterprise WeChat personal app message is the formal approval notification channel after R5; mock and group bots are not acceptance evidence for R6 business notification. -- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. Current WCPC status: existing active test account `SMOKE002/测试员工` (reported only as `oa_smoke***`) reran successfully with report `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`, `status=PASS`, `conclusion=GO_TO_HR_MASTER_SYNC`, and `blockingCapabilities=[]`. Current WHMSLIVE status: `wecom_hr_master_sync_v1` is implemented and deployed with feature flag default/final `force_off`, HR/contact read service, dry-run/apply, sync run/item records, People management card, and QA-only runner `scripts/qa/check-wecom-hr-master-sync.mjs`; public verification applied migration `20260518184319_wecom_hr_master_sync_v1`, temporarily forced the flag on, ran dry-run and explicit apply for `SMOKE002` (`oa_smoke***`), verified ordinary employee API denial with 403, then restored `force_off`. The SMOKE002 apply did not write OA master data because WeCom lacks phone/email/department and was correctly skipped as `missing_required_field`; production real-employee batch apply is not approved. WHMS rules: missing OA-required fields are skipped as `missing_required_field`, inactive WeCom status becomes `needs_offboarding_confirmation`, and OA accounts are not auto-disabled. WOUA-FIX rerun result remains `PASS_WITH_FINDINGS` only because 5 matched WeCom users lack accepted fields and one archived test binding is retained; `oaActiveMissingInWecom=0`, active OA employees 5/5 match WeCom. WRV1 adds QA-only `npm run check:wecom-runtime-ready` to verify entry, OAuth/bindings, dehydrated app-message dry-run, OA deeplink, contact `user/list_id`, HR assistant `get_staff_info`, and check-in short-window reads; WRA1 fixes the check-in gate so `WECOM_CHECKIN_SYNC_ENABLED` remains only the formal sync-batch switch, while QA-only runtime checks pass `requireSyncEnabled=false` for read-only probing and still block on real Secret, trusted IP, visible scope, permission, or WeCom API failures. WRA1 public report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` is `PASS_WITH_CONFIG_GAPS`: entry/OAuth, Secret matrix, OA active bindings, contact, HR assistant, message dry-run, and check-in read passed, with check-in recorded as `readable_sync_disabled`. Real message sending still requires both `--send-test-message` and `WECOM_RUNTIME_TEST_SEND=1`. WAPC2FLIVE has supplied low-risk leave/missed-punch template IDs and test `sp_no` values; WCBK1 has verified real callback URL and one approval-status event. Attendance/approval mirror, compensation pull, readability gate, self-owned archive model, minimum history archive, emergency mode, recovery writeback, RuleOps, outing/overtime samples, and HR write APIs remain later-stage gaps. WAPC1 fixed the approval configuration preparation checklist but did not implement mirror. WAPC2 adds `npm run check:wecom-approval-poc` for leave/missed-punch `gettemplatedetail` and low-risk `getapprovaldetail`; missing template ID or `sp_no` must remain `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`, and API/permission/trusted-IP/scope failures remain `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`. WPROC1 adds Definition of Ready, Definition of Done, automatic-first gates, and non-self approval requirements. WAPC2F defaults to applicant `SMOKE002` and approver `SMOKE101`; both must be active, bound, visible, and not the same OA employee or WeCom userid. If `SMOKE101` is unavailable, stop and ask the user instead of switching to another approver. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished. -- WMSG1 has verified real Enterprise WeChat personal-app delivery and Deeplink return: report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` has `checks.message.status=OK`, `errcode=0`, and `recipientCount=1`; the user confirmed receiving "OA 运行就绪检查" and opening the OA notifications page from the message. This only clears message/deeplink runtime evidence; approval template IDs, approval `sp_no`, callback event confirmation, compensation pull, and deep mirror work remain separate later gates, with the first three now covered by WAPC2FLIVE/WCBK1 in the OA-POC leave/missed-punch scope. +- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. Current WCPC status: existing active test account `SMOKE002/测试员工` (reported only as `oa_smoke***`) reran successfully with report `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`, `status=PASS`, `conclusion=GO_TO_HR_MASTER_SYNC`, and `blockingCapabilities=[]`. Current WHMSLIVE status: `wecom_hr_master_sync_v1` is implemented and deployed with feature flag default/final `force_off`, HR/contact read service, dry-run/apply, sync run/item records, People management card, and QA-only runner `scripts/qa/check-wecom-hr-master-sync.mjs`; public verification applied migration `20260518184319_wecom_hr_master_sync_v1`, temporarily forced the flag on, ran dry-run and explicit apply for `SMOKE002` (`oa_smoke***`), verified ordinary employee API denial with 403, then restored `force_off`. The SMOKE002 apply did not write OA master data because WeCom lacks phone/email/department and was correctly skipped as `missing_required_field`; production real-employee batch apply is not approved. WHMS rules: missing OA-required fields are skipped as `missing_required_field`, inactive WeCom status becomes `needs_offboarding_confirmation`, and OA accounts are not auto-disabled. WOUA-FIX rerun result remains `PASS_WITH_FINDINGS` only because 5 matched WeCom users lack accepted fields and one archived test binding is retained; `oaActiveMissingInWecom=0`, active OA employees 5/5 match WeCom. WRV1 adds QA-only `npm run check:wecom-runtime-ready` to verify entry, OAuth/bindings, dehydrated app-message dry-run, OA deeplink, contact `user/list_id`, HR assistant `get_staff_info`, and check-in short-window reads; WRA1 fixes the check-in gate so `WECOM_CHECKIN_SYNC_ENABLED` remains only the formal sync-batch switch, while QA-only runtime checks pass `requireSyncEnabled=false` for read-only probing and still block on real Secret, trusted IP, visible scope, permission, or WeCom API failures. WRA1 public report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T180506Z/summary.json` is `PASS_WITH_CONFIG_GAPS`: entry/OAuth, Secret matrix, OA active bindings, contact, HR assistant, message dry-run, and check-in read passed, with check-in recorded as `readable_sync_disabled`. Real message sending still requires both `--send-test-message` and `WECOM_RUNTIME_TEST_SEND=1`. WAPC2FLIVE has supplied low-risk leave/missed-punch OA-POC samples in live memory and WCBK1 has verified real callback URL plus one approval-status event; WACP3 added the compensation-pull runner but public live read is still `PARTIAL_COMPENSATION_NEEDS_CONFIG` because full template IDs are not persisted in remote env. Attendance/approval mirror, WACP3 config rerun, readability gate, self-owned archive model, minimum history archive, emergency mode, recovery writeback, RuleOps, outing/overtime samples, and HR write APIs remain later-stage gaps. WAPC1 fixed the approval configuration preparation checklist but did not implement mirror. WAPC2 adds `npm run check:wecom-approval-poc` for leave/missed-punch `gettemplatedetail` and low-risk `getapprovaldetail`; missing template ID or `sp_no` must remain `PARTIAL_APPROVAL_POC_NEEDS_CONFIG`, and API/permission/trusted-IP/scope failures remain `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`. WPROC1 adds Definition of Ready, Definition of Done, automatic-first gates, and non-self approval requirements. WAPC2F defaults to applicant `SMOKE002` and approver `SMOKE101`; both must be active, bound, visible, and not the same OA employee or WeCom userid. If `SMOKE101` is unavailable, stop and ask the user instead of switching to another approver. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished. +- WMSG1 has verified real Enterprise WeChat personal-app delivery and Deeplink return: report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` has `checks.message.status=OK`, `errcode=0`, and `recipientCount=1`; the user confirmed receiving "OA 运行就绪检查" and opening the OA notifications page from the message. This only clears message/deeplink runtime evidence; approval template IDs, approval `sp_no`, callback event confirmation, compensation pull, and deep mirror work remain separate later gates; template/detail and callback evidence are covered by WAPC2FLIVE/WCBK1 in the OA-POC leave/missed-punch scope, while WACP3 still needs full template IDs configured for a PASS rerun. - WAPC1 has fixed the WeCom approval configuration preparation gate: first templates are leave, missed punch, outing, and overtime; each needs template ID, scope, fields, approver rules, visible scope, and self-built app permission; low-risk test `sp_no` must not contain procurement, contract, reimbursement, invoice, bank receipt, sensitive attachment, or real sensitive business material; callback URL/Token/EncodingAESKey are deployment-only values and must not be written to repo docs or Issue CSV. Do not implement real approval callback handling, deep mirror, emergency mode, recovery writeback, or RuleOps until a later approved Issue covers the POC or implementation. - WAPC2 has added the WeCom approval template/detail read-only POC runner for leave and missed punch. Use `npm run check:wecom-approval-poc`; pass template IDs with `--template=leave:` / `--template=missed_punch:` and low-risk test approval numbers with `--sp-no=leave:` / `--sp-no=missed_punch:`. The runner must use only `WECOM_AGENT_SECRET`, redact full template IDs and `sp_no`, and never output form values, attachments, comments, full userid, token, Secret, or raw payload. - 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. diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 68a97d9f..8f8af6de 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -58,6 +58,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 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 回调验签、解密和审批事件解析通过。配置真实回调后,远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 达到 `PASS_CALLBACK_URL_VERIFIED`;用户随后由 `SMOKE101` 同意一条现有 OA-POC 补卡测试审批单,复跑报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 达到 `PASS_CALLBACK_EVENT_CONFIRMED`,脱敏证据记录 1 条已通过审批状态事件。本轮不建镜像表、不写 OA 业务数据、不写企微数据、不创建新模板或审批单。 - 2026-05-19 WEGOV1 完成企微主办治理与 OA 自建留存路线修正:本轮只更新 Issue CSV 和事实源,不改业务代码、不部署、不写 OA/企微数据。结论固定为“企微成熟功能优先上线,OA 做自建数据留存、审计、入口、消息和应急兜底”;HR/考勤日常流程优先企微主办,报销、采购、合同、项目、付款、发票、银行回单、高敏附件和经营审计继续 OA 主办。后续顺序调整为 `WACP3 -> WREAD1 -> WADM1 -> WARCH1 -> wecom_attendance_approval_mirror_v1`,其中 WARCH1 先保证企微不可用时可查看已保存历史,再进入完整镜像增强。 +- 2026-05-19 WACP3 实现企微审批补偿拉取只读 POC runner:新增 `server/src/scripts/check-wecom-approval-compensation-poc.js` / `npm run check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 调用 `POST /cgi-bin/oa/getapprovalinfo`,按最近 72 小时和 `template_id` filters 拉审批单号,再复用 `getapprovaldetail` 拉详情并与 WCBK1 回调证据脱敏比对。本地 CSV、process gate、Prisma、node --check、focused vitest、`scripts/check-minimal.sh` 均通过;已以 `SYNC_ENV=0` 部署并复检 `/ready`。公网只读报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`:Secret 矩阵 OK,但远端缺 `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` 和 `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID`,因此未拉取审批详情、未比对回调事件、未写 OA/企微数据。 ## 关键证据索引 @@ -114,6 +115,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | 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 | 已完成公网真实回调只读 POC:只读回调端点和 synthetic runner 通过本地加密链路;远端配置真实回调后达到 `PASS_CALLBACK_URL_VERIFIED`,`SMOKE101` 同意现有 OA-POC 补卡测试单后达到 `PASS_CALLBACK_EVENT_CONFIRMED`;只保存脱敏事件证据,不写业务镜像 | `issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` | | WEGOV1 | 已完成 docs-only 路线治理:固定企微主办域、OA 主办域、OA 留存域、OA 应急域、受控测试域和自动化/人工边界;明确 WCBK1 证据不直接放行完整镜像,后续先做 WACP3、WREAD1、WADM1 和 WARCH1 | `issues/2026-05-19_18-17-44-wecom-governance-owned-oa-archive-route-v1.csv` | +| WACP3 | 已实现并公网只读复跑:新增补偿拉取 runner 和 `getapprovalinfo` 只读封装;远端报告为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,缺完整请假/补卡模板 ID 过滤参数,未拉详情、未比对回调、未写数据 | `issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` | ## 当前门禁 diff --git a/docs/企微API能力目录.md b/docs/企微API能力目录.md index 2c0b09b3..306de9a3 100644 --- a/docs/企微API能力目录.md +++ b/docs/企微API能力目录.md @@ -2,8 +2,8 @@ **文档状态**:当前企微 API 调研与本项目使用事实源。 **分类**:集成事实源 / API 能力目录。 -**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 之后。 -**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1`。 +**适用时间**:WAPI / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 之后。 +**关联 Issue**:`WAPI,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3`。 **最近核对**:2026-05-19。 **不得误读**:本文只记录本项目会用到的企业微信官方 API 摘要、链接和项目决策,不复制官方文档全文,不保存 Secret、access token、完整 userid、手机号、邮箱或企微原始 payload。官方文档和企业微信后台权限配置始终是最终依据。 @@ -60,7 +60,7 @@ | 审批模板详情 | [获取审批模板详情](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/91815) | 事件回调 | `GET` URL 验证 / `POST` 事件 | 自建应用回调 Token/EncodingAESKey | `CAN_REUSE_NOW_FOR_CALLBACK_POC` | WCBK1 已部署只读 `GET/POST /api/wecom/approval/callback` 和 `check:wecom-approval-callback-poc`;公网 synthetic 验签/解密/解析通过后,远端配置真实回调并完成 URL 验证,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 为 `PASS_CALLBACK_URL_VERIFIED`;`SMOKE101` 同意现有 OA-POC 补卡测试单后,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。该能力仅证明只读回调链路和脱敏证据,不代表镜像入库已实现。 | -| 批量获取审批单号 | [批量获取审批单号](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) | `/cgi-bin/oa/getapprovalinfo` | `POST` | `WECOM_AGENT_SECRET` | `PARTIAL_COMPENSATION_NEEDS_CONFIG` | WACP3 已新增 `check:wecom-approval-compensation-poc`,按时间窗口和 `template_id` filters 拉审批单号,再用 `getapprovaldetail` 拉详情并与 WCBK1 回调证据比对;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 显示 Secret 可用,但缺完整请假/补卡模板 ID 环境变量,暂未证明 live 拉取通过。 | | 提交审批申请 | [提交审批申请](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/97437)、[更新审批模板](https://developer.work.weixin.qq.com/document/path/97438) | `/cgi-bin/oa/approval/create_template` 等官方模板写接口 | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | WAPC2FLIVE 已用 `template_name` + `property` controls 结构创建 OA-POC 请假/补卡测试模板并保留审计。正式模板更新、外出/加班模板、RuleOps 写入仍留后期合同。 | @@ -106,7 +106,7 @@ WPROC1 将 WAPC2 之后的审批路线固定为: - `WCBK1` 必须先证明真实回调 URL 验证、验签、解密、幂等键和重复投递,不保存 Token、EncodingAESKey 或原始密文。 - `WCBK1` 已完成真实回调 POC:`npm run check:wecom-approval-callback-poc -- --live` 先达到 `PASS_CALLBACK_URL_VERIFIED`,随后由 `SMOKE101` 同意一条现有 OA-POC 补卡测试审批单并复跑达到 `PASS_CALLBACK_EVENT_CONFIRMED`。报告只记录事件类型、审批类型、状态、脱敏 `sp_no`/模板前缀和幂等键摘要,不保存 Token、EncodingAESKey、原始 XML、密文、完整 `sp_no`、完整 userid、表单值、附件或评论。 - `WEGOV1` 固定混合架构:企微主办域为 HR/考勤成熟功能,OA 主办域为财务经营和高敏业务,OA 留存域保存企微关键历史和完整外部关联键,OA 应急域只在企微不可用时人工开启。 -- `WACP3` 必须先证明按时间窗口批量获取审批单号并可调用 `getapprovaldetail` 拉详情;缺权限、可信 IP 或可见范围时记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 +- `WACP3` 已实现补偿拉取 runner,但当前公网只读复跑为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,原因是远端缺 `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` 和 `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID`;后续补齐完整模板 ID 后复跑,必须真实输出 `PASS_COMPENSATION_POC`、`PASS_WITH_FINDINGS` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`,不得无过滤拉取高风险业务审批详情。 - `WREAD1` 必须用真机或代表设备确认审批消息、审批列表和详情页的可读性;若快捷同意/驳回容易误操作,则模板调整或该模板回退 OA 主办。 - `WADM1` 必须先设计自建留存和镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。 - `WARCH1` 必须先实现最小自建历史留存,让 OA 在企微 API 不可用时仍可查看已保存历史,再进入完整镜像增强。 diff --git a/docs/企微优先开发路线图.md b/docs/企微优先开发路线图.md index 22c20462..72f0937c 100644 --- a/docs/企微优先开发路线图.md +++ b/docs/企微优先开发路线图.md @@ -2,10 +2,10 @@ **文档状态**:当前开发路线图事实源。 **分类**:开发治理 / 路线图。 -**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 之后。 -**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1`。 +**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 之后。 +**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3`。 **最近审计**: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`。WCBK1 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。该证据和路线只证明企微主办 HR/考勤的可用基础,不代表补偿拉取、可读性门禁、自建留存模型、最小历史留存或 `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 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网报告为 `PASS_CALLBACK_EVENT_CONFIRMED`;WEGOV1 后路线明确为“企微成熟功能优先上线,OA 做自建留存、审计、入口、消息和应急兜底”。WACP3 已新增补偿拉取只读 runner 并完成公网复跑,但当前结论为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`:远端缺请假/补卡完整模板 ID 环境变量,尚未证明按窗口拉取 OA-POC 审批单号和详情。上述证据只证明企微主办 HR/考勤的可用基础,不代表补偿拉取已 PASS、可读性门禁、自建留存模型、最小历史留存或 `wecom_attendance_approval_mirror_v1` 已完成。 ## 1. 路线图原则 @@ -44,6 +44,7 @@ | `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` | 真实回调 URL 与事件已确认 | 新增只读 `GET/POST /api/wecom/approval/callback`、`npm run check:wecom-approval-callback-poc` 和 `wecom-callback` process gate。远端配置回调 URL、Token 和 EncodingAESKey 后,live runner 报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 达到 `PASS_CALLBACK_URL_VERIFIED`;用户随后由 `SMOKE101` 同意一条现有 OA-POC 补卡测试审批单,复跑报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 达到 `PASS_CALLBACK_EVENT_CONFIRMED`,脱敏证据记录 `eventCount=1`、状态 `已通过`。 | | `WEGOV1` | 路线和治理已调整 | 明确混合架构:企微成熟功能优先上线,OA 做自建数据留存、审计、入口、消息和应急兜底;固化受控测试域、自动化优先和人工边界;后续顺序调整为 WACP3、WREAD1、WADM1、WARCH1 后再评估深度镜像。 | +| `WACP3` | 已实现并公网只读复跑,当前缺配置 | 新增 QA-only `npm run check:wecom-approval-compensation-poc` 和 `POST /cgi-bin/oa/getapprovalinfo` 只读封装,固定 `WECOM_AGENT_SECRET`,默认最近 72 小时窗口并仅按请假/补卡模板 ID 过滤拉取审批单号,再复用 `getapprovaldetail` 拉详情。已以 `SYNC_ENV=0` 部署,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`:Secret 矩阵 OK,但缺 `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` 和 `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID`,因此未拉详情、未比对回调、未写 OA/企微数据。 | ## 3. 阶段路线 @@ -57,7 +58,7 @@ | 6 | `WAPC2 自动复跑` | 使用 WAPC2F 生成的脱敏模板 ID 和 `sp_no` 自动复跑只读 POC。 | WAPC2F live 写入生成全部或部分样件。 | 已完成:live 报告中 `checks.autoRerun.status=OK`,请假/补卡模板详情与审批详情可读,结论 `PASS_APPROVAL_POC`。 | | 7 | `WCBK1 回调只读 POC` | 证明真实回调 URL 验证、验签、解密、幂等键和重复投递处理。 | 回调 URL、Token、EncodingAESKey 由部署环境配置;不得写入仓库或聊天。 | 已完成:`PASS_CALLBACK_EVENT_CONFIRMED`。远端 live URL 验证通过后,由 `SMOKE101` 处理现有 OA-POC 补卡测试单触发真实事件;报告只记录脱敏事件类型、状态、`sp_no`/模板前缀和幂等键摘要。 | | 8 | `WEGOV1 企微主办治理与自建留存路线修正` | 固化混合主办边界、OA 自建留存策略、受控测试域和后续顺序。 | WCBK1 已确认真实回调事件;用户选择优先利用企微成熟功能并要求 OA 保存关键历史。 | docs-only 收口;文档明确企微主办域、OA 主办域、OA 留存域、OA 应急域、自动化与人工边界。 | -| 9 | `WACP3 补偿拉取 POC` | 证明按时间窗口批量获取审批单号并可拉取审批详情,作为回调丢失补偿。 | WEGOV1 完成;WAPC2/WAPC2FLIVE/WCBK1 已提供低风险样例、详情读取和真实事件证据;官方 API 和权限明确。 | 能按窗口拿到审批单号并拉详情;能比对 WCBK1 回调事件;不能伪造成 PASS,权限或可信 IP 问题记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 | +| 9 | `WACP3 补偿拉取 POC` | 证明按时间窗口批量获取审批单号并可拉取审批详情,作为回调丢失补偿。 | WEGOV1 完成;WAPC2/WAPC2FLIVE/WCBK1 已提供低风险样例、详情读取和真实事件证据;官方 API 和权限明确。 | 已实现 runner 并公网复跑;当前远端结论为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,缺完整请假/补卡模板 ID 过滤参数。补齐后需复跑到 `PASS_COMPENSATION_POC` 或真实 `PASS_WITH_FINDINGS/SKIP`,才能作为 WADM1 输入。 | | 10 | `WREAD1 企微审批可读性与误审批防护门禁` | 真机确认请假、补卡、外出、加班在企微里是否看得清再批,评估快捷同意/驳回误操作风险。 | WACP3 完成或并行准备低风险样例;审批模板可在企微手机端由代表审批人查看。 | 输出 `PASS_READABLE`、`PASS_WITH_TEMPLATE_ADJUSTMENTS` 或 `FAIL_KEEP_OA_PRIMARY_FOR_THIS_TEMPLATE`;不可读模板不得进入正式镜像。 | | 11 | `WADM1 自建留存/镜像数据模型设计` | 先设计 OA 留存字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。 | WACP3 证明补偿拉取可用;WREAD1 明确模板可读性或回退策略。 | 模型覆盖审批、打卡、人事关键快照、回调事件和补偿拉取记录;设计经 Issue CSV 固化后才允许 schema 或业务代码实现。 | | 12 | `WARCH1 最小自建历史留存实现` | 只读保存企微审批、打卡、人事关键快照,先满足企微不可用时可查看历史。 | WADM1 完成并批准 schema/接口实现。 | OA 可查看已保存历史摘要和完整外部关联键;报告和日志只输出脱敏前缀;不保存 Secret、token、EncodingAESKey、原始密文或无治理原始 payload。 | diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 9fc8000b..cd963902 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -84,6 +84,7 @@ | `WAPC2FLIVE` | `issues/2026-05-19_13-24-12-wecom-approval-fixture-live-create-v1.csv` | `CURRENT` | 企微审批 OA-POC 样件 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` | `issues/2026-05-19_14-11-20-wecom-approval-callback-readonly-poc-v1.csv` | `CURRENT` | 企微审批回调只读 POC 已完成公网真实事件验证:只读回调端点、验签/AES 解密/归一化服务和 runner 已部署;远端报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` 为 `PASS_CALLBACK_URL_VERIFIED`,`SMOKE101` 同意现有 OA-POC 补卡测试单后复跑 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`;仍不代表补偿拉取、镜像模型或深度镜像已完成 | | `WEGOV1` | `issues/2026-05-19_18-17-44-wecom-governance-owned-oa-archive-route-v1.csv` | `CURRENT` | 企微主办治理与 OA 自建留存路线修正:docs-only 固定企微主办域、OA 主办域、OA 留存域、OA 应急域、受控测试域、自动化/人工边界和后续顺序;WCBK1 后仍需 WACP3、WREAD1、WADM1、WARCH1,不能直接进入深度镜像 | +| `WACP3` | `issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv` | `CURRENT` | 企微审批补偿拉取 POC 已实现 runner 并公网只读复跑:新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装,固定 `WECOM_AGENT_SECRET`;远端报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,缺完整请假/补卡模板 ID 过滤参数,未拉详情、未比对回调、未写 OA/企微数据 | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 diff --git a/docs/文档索引.md b/docs/文档索引.md index 29e94d2a..dbff7ad9 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -77,6 +77,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - 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-20260519T083422Z/summary.json` 为 `PASS_CALLBACK_URL_VERIFIED`,`SMOKE101` 同意现有 OA-POC 补卡测试单后报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。本证据不代表补偿拉取、镜像模型或深度镜像已完成。 - WEGOV1 是 WCBK1 后的企微主办治理与 OA 自建留存路线修正合同:只更新合同和事实源,不改业务代码、不部署、不写 OA/企微数据;固定混合主办边界、OA 自建历史留存、企业微信无完整 sandbox 假设、受控测试域、自动化与人工边界,并把后续顺序调整为 `WACP3 -> WREAD1 -> WADM1 -> WARCH1 -> wecom_attendance_approval_mirror_v1`。 +- WACP3 是 WEGOV1 后的企微审批补偿拉取 POC 合同:新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`;远端 Secret 可用,但缺完整请假/补卡模板 ID,未证明补偿拉取 PASS。 ## 当前活跃治理流 @@ -141,6 +142,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `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:真实 URL 和事件已确认 | 新增只读 `GET/POST /api/wecom/approval/callback`、`check:wecom-approval-callback-poc` 和 `wecom-callback` process gate;远端报告 `PASS_CALLBACK_URL_VERIFIED` 后,`SMOKE101` 处理现有 OA-POC 测试单并复跑达到 `PASS_CALLBACK_EVENT_CONFIRMED` | 不改 schema/UI;不建镜像表;不写 OA/企微业务数据;不创建新模板或审批单;后续仍需 WEGOV1/WACP3/WREAD1/WADM1/WARCH1 | | `WEGOV1` | `issues/2026-05-19_18-17-44-wecom-governance-owned-oa-archive-route-v1.csv` | 当前路线治理:企微主办 + OA 自建留存 | 固定 HR/考勤企微主办、财务经营和高敏业务 OA 主办、OA 留存关键企微历史、企业微信受控测试域、自动化与人工边界;后续新增 WREAD1 和 WARCH1 门禁 | docs-only;不改业务代码/API/schema/UI;不部署;不写 OA/企微数据;不进入深度镜像 | +| `WACP3` | `issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv` | 当前补偿拉取 POC:runner 已部署,公网缺模板 ID 配置 | 新增 `check:wecom-approval-compensation-poc`,固定 `WECOM_AGENT_SECRET` 只读调用 `getapprovalinfo` 和 `getapprovaldetail`;远端报告为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,缺 `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` 和 `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID` | 不新增公开 API/schema/UI;不写 OA/企微数据;不无过滤拉取高风险审批详情;不进入深度镜像 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 diff --git a/docs/管理员手册.md b/docs/管理员手册.md index a39b96fc..a13a4530 100644 --- a/docs/管理员手册.md +++ b/docs/管理员手册.md @@ -1,6 +1,6 @@ # hyb-oa 管理员手册(10 人以下团队版) -**当前状态(2026-05-19 / WEGOV1)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1 和 WEGOV1 均已收口;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 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 明确后续仍需 WACP3 补偿拉取 POC、WREAD1 审批可读性门禁、WADM1 自建留存/镜像数据模型设计和 WARCH1 最小历史留存,不能直接进入 `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 / WACP3)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1 和 WEGOV1 均已收口;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 已部署只读回调端点和 runner,远端完成回调 URL 验证并收到一条由 `SMOKE101` 同意现有 OA-POC 补卡测试单触发的真实审批状态事件,报告 `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` 为 `PASS_CALLBACK_EVENT_CONFIRMED`。WACP3 已新增补偿拉取 runner 并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json` 为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`:远端 `WECOM_AGENT_SECRET` 可用,但缺完整请假/补卡模板 ID 环境变量,尚未证明按窗口拉取审批单号和详情。后续仍需补齐 WACP3 配置复跑、WREAD1 审批可读性门禁、WADM1 自建留存/镜像数据模型设计和 WARCH1 最小历史留存,不能直接进入 `wecom_attendance_approval_mirror_v1`。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。 ## 1. 适用范围 - 适用于 `small_team_internal` 工作区模式。 diff --git a/docs/需求-企业微信基础设施规约.md b/docs/需求-企业微信基础设施规约.md index ed71ff3a..71febbc8 100644 --- a/docs/需求-企业微信基础设施规约.md +++ b/docs/需求-企业微信基础设施规约.md @@ -6,7 +6,7 @@ **上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) **相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[企微 API 能力目录](./企微API能力目录.md) -本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 自建留存、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 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网结论 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后固定“企微主办 HR/考勤成熟能力,OA 保存关键历史和外部关联键”的路线;后续仍必须先做补偿拉取、审批可读性门禁、自建留存/镜像数据模型和最小历史留存,不是直接进入审批镜像实现。 +本规约细化企业微信在 `hyb-oa` 中的基础设施边界、入口、免登/绑定、通讯录身份、消息通知、deeplink、打卡、人事助手、原生审批、OA 自建留存、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 已完成真实回调 URL 验证和一条现有 OA-POC 测试审批单事件确认,公网结论 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后固定“企微主办 HR/考勤成熟能力,OA 保存关键历史和外部关联键”的路线。WACP3 已实现补偿拉取只读 runner 并公网复跑,但当前因缺完整请假/补卡模板 ID 环境变量返回 `PARTIAL_COMPENSATION_NEEDS_CONFIG`;后续仍必须补齐补偿拉取 PASS 或真实 findings、完成审批可读性门禁、自建留存/镜像数据模型和最小历史留存,不是直接进入审批镜像实现。 --- @@ -36,7 +36,7 @@ | 通讯录 | 成员创建、读取、更新、禁用或删除 | 近期已验证 OA 写企微;后续改为企微主源同步 OA,OA 保留修复和审计 | `CAN_REUSE_NOW`:HCLT create/update/disable、部门读取和 WCPC 既有在职测试号 `contact.member_read` 均已通过 | | 人事助手 | 员工字段配置、花名册读取、花名册更新 | 作为员工主数据源,驱动 OA 员工、账号、角色、绑定和状态镜像 | `LOCAL_IMPLEMENTED_FOR_READ_SYNC`:字段配置和既有在职测试号花名册读取已通过;WHMS 已本地实现只读同步到 OA;资料更新写接口仍需单独 POC | | 打卡 | 打卡记录、员工规则、日报、月报、排班、补卡、规则管理 | 企微主办规则和采集,OA 同步按域镜像和应急对账 | `CAN_REUSE_NOW_FOR_READ`:WCPC 短窗口打卡记录和规则读取通过;日报/月报/排班等细项留到考勤镜像合同继续验证 | -| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WCBK1_PASS_CALLBACK_EVENT_CONFIRMED`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证。补偿拉取和镜像数据模型仍需在考勤/审批镜像前补齐 | +| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、批量获取审批单号、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WACP3_PARTIAL_COMPENSATION_NEEDS_CONFIG`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证;WACP3 已实现 `getapprovalinfo` 补偿拉取 runner,但远端缺完整模板 ID 过滤参数,尚未证明 live 拉取通过。镜像数据模型仍需在考勤/审批镜像前补齐 | | 工作台和消息 | 自建应用入口、应用消息、deeplink | 继续作为 OA 移动入口和通知渠道 | `CAN_REUSE_NOW`;WRV1 新增 `check:wecom-runtime-ready` 对入口、OAuth、脱敏消息 dry-run 和 deeplink 做运行就绪门禁 | | 财务/报销 | 企微不提供本项目需要的 PDF OCR、付款批次、银行回单和代账闭环 | OA 主办 | `KEEP_OA_PRIMARY` | | 真实支付、ERP、图片 OCR、银行流水导入 | 非本路线目标 | 不进入当前路线 | `NOT_IN_SCOPE` | diff --git a/docs/需求-统一审批中心规约.md b/docs/需求-统一审批中心规约.md index 9ffca186..80d1b80e 100644 --- a/docs/需求-统一审批中心规约.md +++ b/docs/需求-统一审批中心规约.md @@ -5,7 +5,7 @@ **状态**:模块级开发规约 **上级事实源**:[海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) -本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。后续仍必须先完成 WACP3 补偿拉取 POC、WREAD1 审批可读性与误审批防护门禁、WADM1 自建留存/镜像数据模型设计和 WARCH1 最小历史留存,不能直接放行镜像实现。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。 +本规约细化 `hyb-oa` 审批中心和企业微信原生审批镜像的产品边界、角色权限、状态模型、异常规则、留痕要求和验收场景。WFOC 起,审批按主办方分层;DBWFOC 后边界进一步收窄:报销、财务、采购、合同和项目类审批继续由 OA 主办;请假、补卡、外出、加班等 HR/考勤员工日常审批优先由企业微信主办,OA 同步按域镜像并保留自建历史留存、人工接管和恢复回写能力。WMSG1 已验证企业微信应用消息和 Deeplink 回流,WAPC1 后审批镜像前置工作进入配置准备清单阶段,WAPC2 已提供请假/补卡只读 POC runner,WAPC2FLIVE 已自动生成请假/补卡 OA-POC 样件并复跑通过。WCBK1 已完成只读回调 URL 验证和真实审批事件确认,结论为 `PASS_CALLBACK_EVENT_CONFIRMED`。WEGOV1 后明确先利用企微成熟审批和考勤能力,同时由 OA 保存关键历史和外部关联键。WACP3 已实现补偿拉取只读 runner 并公网复跑,当前结论为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,原因是远端缺请假/补卡完整模板 ID 过滤参数。后续仍必须补齐 WACP3 PASS 或真实 findings、完成 WREAD1 审批可读性与误审批防护门禁、WADM1 自建留存/镜像数据模型设计和 WARCH1 最小历史留存,不能直接放行镜像实现。后续审批相关开发必须先从本规约拆分 Issue CSV,再进入技术方案和代码实现。 --- @@ -416,7 +416,7 @@ WAPC2F 是 WAPC2 后的自动化优先补缺阶段,目标是尽量用 QA-only - `WCBK1 回调只读 POC`:已新增只读 `GET/POST /api/wecom/approval/callback` 和 `npm run check:wecom-approval-callback-poc`。公网 synthetic 验签、解密和解析通过后,部署环境完成回调配置并达到 `PASS_CALLBACK_URL_VERIFIED`;`SMOKE101` 随后处理现有 OA-POC 补卡测试单触发真实事件,复跑结论 `PASS_CALLBACK_EVENT_CONFIRMED`,报告只保存脱敏事件证据。 - `WEGOV1 企微主办治理与自建留存路线修正`:固定企微主办域、OA 主办域、OA 留存域和 OA 应急域;明确企业微信没有完整 sandbox 假设,默认使用受控测试域和自动化 runner。 -- `WACP3 补偿拉取 POC`:证明可按时间窗口获取审批单号,并调用 `getapprovaldetail` 拉取详情。 +- `WACP3 补偿拉取 POC`:已新增 `check:wecom-approval-compensation-poc` 和 `getapprovalinfo` 只读封装;公网复跑为 `PARTIAL_COMPENSATION_NEEDS_CONFIG`,缺 `WECOM_APPROVAL_LEAVE_TEMPLATE_ID` 和 `WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID`,尚未拉取 OA-POC 审批详情。补齐模板 ID 后需复跑证明可按时间窗口获取审批单号,并调用 `getapprovaldetail` 拉取详情。 - `WREAD1 审批可读性与误审批防护门禁`:真机确认审批消息卡片、审批列表和详情页是否足够清楚;若快捷同意/驳回存在高误操作风险,则要求模板调整或该模板回退 OA 主办。 - `WADM1 自建留存/镜像数据模型设计`:先设计字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。 - `WARCH1 最小自建历史留存实现`:先只读保存企微审批、打卡和人事关键快照,保证企微不可用时可查看历史,再进入完整镜像增强。 diff --git a/issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv b/issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv new file mode 100644 index 00000000..cab67de4 --- /dev/null +++ b/issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv @@ -0,0 +1,9 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"WACP3-0","合同建档与边界冻结","建立 WACP3 企微审批补偿拉取 POC 合同;本轮只读验证按时间窗口批量获取审批单号、拉审批详情并与 WCBK1 回调证据脱敏比对,不新建企微模板或审批单。","1) CSV 校验通过;2) 范围限定 QA-only 只读 runner、企微审批只读服务、focused 单测和事实源回写;3) 固定使用 WECOM_AGENT_SECRET;4) 不调用企微写接口、不写 OA/企微业务数据、不进入 wecom_attendance_approval_mirror_v1。","受影响流程:企微主办审批 -> 回调可能漏投 -> OA 补偿拉取 POC;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv","[]","已完成合同建档和边界冻结;本轮无 OA/企微写入、无新增模板或审批单。回滚/清理:删除本合同和本轮代码/文档变更;本轮不部署写入能力、不创建企微数据、不写数据库。" +"WACP3-1","审批补偿拉取接口调研与服务扩展","在企微审批集成服务中新增只读批量获取审批单号能力,固定 WECOM_AGENT_SECRET 调用 POST /cgi-bin/oa/getapprovalinfo,并复用 getapprovaldetail 拉详情。","1) 服务导出批量获取审批单号函数;2) 请求支持 starttime/endtime/size/cursor/template_id filters;3) 企微错误返回脱敏分类;4) 不尝试其他 Secret、无 fallback;5) 响应不输出完整 sp_no、template_id、userid 或原始 payload。","受影响流程:企微审批补偿拉取候选 -> 审批详情读取;验证步骤:cd server && node --check src/services/integrations/wecomApproval.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/services/integrations/wecomApproval.js","[""WACP3-0""]","已新增 getapprovalinfo 只读能力并固定 WECOM_AGENT_SECRET;缺权限、可信 IP、可见范围或审批数据权限时输出 SKIP/BLOCKED_BY_EXTERNAL_CONFIG。回滚/清理:移除新增只读函数和导出。" +"WACP3-2","QA-only 补偿拉取 runner","新增 check-wecom-approval-compensation-poc runner 和 package script,默认最近 72 小时窗口,支持 --start-time、--end-time、--template=leave:、--template=missed_punch:、--size=100,并输出脱敏 summary.json。","1) package.json 增加 check:wecom-approval-compensation-poc;2) 默认只读,不存在 live 写入参数或写入环境开关;3) dry-run 只构建计划和配置检查;4) live 只调用 getapprovalinfo/getapprovaldetail;5) 报告结论限定 PASS_COMPENSATION_POC、PASS_WITH_FINDINGS、PARTIAL_COMPENSATION_NEEDS_CONFIG、SKIP/BLOCKED_BY_EXTERNAL_CONFIG。","受影响流程:审批补偿拉取 POC runner -> 脱敏报告;验证步骤:cd server && node --check src/scripts/check-wecom-approval-compensation-poc.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-compensation-poc.js|server/package.json","[""WACP3-1""]","已新增 QA-only runner 和 package script;隐私边界:报告不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、表单值、附件、评论或原始 payload。回滚/清理:删除 runner 和 package script。" +"WACP3-3","回调证据比对与差异分类","runner 读取 WCBK1 脱敏回调事件证据,与补偿拉取到的审批详情做脱敏比对,输出漏回调、重复回调、状态不一致和样例不完整等发现。","1) 能读取 WECOM_APPROVAL_CALLBACK_EVIDENCE_FILE 或默认证据文件;2) 按脱敏 sp_no/template/status 摘要比对;3) 无回调证据时返回 PARTIAL_COMPENSATION_NEEDS_CONFIG;4) 有差异时返回 PASS_WITH_FINDINGS;5) 不保存原始 XML、密文或完整 sp_no。","受影响流程:真实回调事件 -> 补偿拉取复核 -> 差异审计;验证步骤:cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/scripts/check-wecom-approval-callback-poc.test.js --reporter=verbose。","vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-compensation-poc.js|server/src/services/integrations/wecomCallbackCrypto.js","[""WACP3-2""]","已实现回调证据脱敏比对;公网因缺模板 ID 未进入详情拉取和比对。本轮不写入镜像表,只输出 QA-only 脱敏报告。回滚/清理:移除比对逻辑,不影响 WCBK1 已有回调接收。" +"WACP3-4","Focused 单测与隐私回归","新增 focused vitest 覆盖 WACP3 runner、服务函数、错误分类、回调证据比对和隐私边界。","1) 覆盖固定 WECOM_AGENT_SECRET;2) 覆盖无 Secret fallback;3) 覆盖分页/窗口参数和 template filters;4) 覆盖审批单号拉取和详情拉取;5) 覆盖空窗口、API 错误、回调证据缺失、状态不一致;6) 覆盖隐私字段不泄露。","受影响流程:WACP3 本地质量门禁;验证步骤:cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/scripts/check-wecom-approval-poc.test.js tests/unit/scripts/check-wecom-approval-callback-poc.test.js --reporter=verbose。","vitest","DONE","DONE","DONE","server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js","[""WACP3-3""]","focused vitest 已覆盖固定 Secret、无 fallback、窗口/分页、详情拉取、错误分类、比对和隐私边界;单测使用 mock,不访问公网,不写 OA/企微数据。回滚/清理:删除测试文件。" +"WACP3-5","事实源回写","更新路线图、企微 API 能力目录、统一审批规约、企业微信基础设施规约、试运行报告、文档索引、事实源矩阵和项目 skill,登记 WACP3 当前能力和仍未放行镜像。","1) 文档记录 WACP3 只证明补偿拉取 POC;2) 文档仍要求 WREAD1、WADM1、WARCH1 后才能评估 deep mirror;3) 文档登记 PASS/PARTIAL/SKIP 真实结论;4) skill 提醒后续企微审批先读 WACP3 证据。","受影响流程:后续企微审批路线、事实源读取和人工问答;验证步骤: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 'WACP3|补偿拉取|getapprovalinfo|PASS_COMPENSATION_POC|WREAD1' docs .agents/skills/hyb-oa-delivery/SKILL.md。","node|python3|rg","DONE","DONE","DONE","docs|.agents/skills/hyb-oa-delivery/SKILL.md","[""WACP3-4""]","事实源已回写 WACP3 runner、公网只读结论和仍未放行镜像的门禁。回滚/清理:只撤回事实源文字;不改变业务运行状态。" +"WACP3-6","本地门禁与公网只读复跑","本地门禁通过后以 SYNC_ENV=0 受控部署,远端只读执行 check:wecom-approval-compensation-poc,不新建企微数据。","1) process baseline 和 WACP3 issue profile 通过;2) node --check、prisma validate、focused vitest、文档审计、skill 校验、git diff --check 和 scripts/check-minimal.sh 通过;3) SYNC_ENV=0 部署;4) /ready 正常;5) 远端报告为 PASS_COMPENSATION_POC、PASS_WITH_FINDINGS、PARTIAL_COMPENSATION_NEEDS_CONFIG 或真实 SKIP/BLOCKED_BY_EXTERNAL_CONFIG。","受影响流程:公网企微审批补偿拉取只读验证;验证步骤:node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-compensation-poc.test.js tests/unit/scripts/check-wecom-approval-poc.test.js tests/unit/scripts/check-wecom-approval-callback-poc.test.js --reporter=verbose;node scripts/qa/audit-docs.mjs --check-registry;bash scripts/check-minimal.sh;SYNC_ENV=0 bash deploy/trialrun/deploy.sh;远端 npm run check:wecom-approval-compensation-poc -- --live --format=json;复检 /ready。","node|npm|vitest|bash|ssh|curl","DONE","DONE","DONE","N/A","[""WACP3-5""]","本地门禁、SYNC_ENV=0 部署和 /ready 已通过;公网只读报告 /app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T112117Z/summary.json 结论为 PARTIAL_COMPENSATION_NEEDS_CONFIG,原因是远端缺 WECOM_APPROVAL_LEAVE_TEMPLATE_ID、WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID,未拉详情、未比对回调;本轮无 OA/企微写入。回滚/清理:部署回滚沿用 deploy 流程;本轮无企微写入可清理。" +"WACP3-7","状态回填与中文提交","按真实测试和公网复跑结果回填 WACP3 Issue 状态,完成中文提交。","1) WACP3 全部 Test_Method 已执行并通过或真实 SKIP;2) Issue Notes 记录本地和公网报告路径、结论和剩余缺口;3) git diff --check 通过;4) 中文 commit 引用 WACP3。","受影响流程:WACP3 交付闭环;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-19_19-03-06-wecom-approval-compensation-pull-poc-v1.csv;git diff --check;git status --short;git commit -m 'WACP3 实现企微审批补偿拉取 POC'。","python3|git","DONE","DONE","DONE","N/A","[""WACP3-6""]","已按真实结果回填:公网为 PARTIAL_COMPENSATION_NEEDS_CONFIG,不伪造成 PASS;剩余人工/配置项是把完整请假和补卡 OA-POC 模板 ID 配到远端环境后复跑。回滚/清理:git revert 本提交;无数据库迁移、无 OA/企微写入。" diff --git a/server/package.json b/server/package.json index 52931f4a..018f0aba 100644 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "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-callback-poc": "node src/scripts/check-wecom-approval-callback-poc.js", + "check:wecom-approval-compensation-poc": "node src/scripts/check-wecom-approval-compensation-poc.js", "check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.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", diff --git a/server/src/scripts/check-wecom-approval-compensation-poc.js b/server/src/scripts/check-wecom-approval-compensation-poc.js new file mode 100644 index 00000000..7cdc3f49 --- /dev/null +++ b/server/src/scripts/check-wecom-approval-compensation-poc.js @@ -0,0 +1,741 @@ +#!/usr/bin/env node +require("dotenv/config"); + +const fs = require("node:fs"); +const path = require("node:path"); + +const { getConfig } = require("../config"); +const { + APPROVAL_GET_INFO_ENDPOINT, + APPROVAL_POC_TARGETS, + fetchWecomApprovalDetail, + fetchWecomApprovalInfoList, + maskApprovalIdentifier, +} = require("../services/integrations/wecomApproval"); +const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto"); + +const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"); +const DEFAULT_WINDOW_HOURS = 72; +const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_MAX_PAGES = 10; + +const REPORT_PRIVACY = + "approval counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload"; + +const TEMPLATE_ENV = Object.freeze({ + leave: "WECOM_APPROVAL_LEAVE_TEMPLATE_ID", + missed_punch: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID", +}); + +function printHelp() { + process.stdout.write(`Usage: node src/scripts/check-wecom-approval-compensation-poc.js [options] + +QA-only 企业微信审批补偿拉取 POC。 +默认 dry-run 只检查配置和计划;--live 仅做只读 getapprovalinfo/getapprovaldetail,不写 OA/企微数据。 + +Options: + --live 启用企微 live 只读拉取。 + --format=text|json 输出格式,默认 text。 + --report-dir= 报告目录,默认 artifacts/qa-flow/wecom-approval-compensation-poc-。 + --start-time= 窗口开始时间,默认 end-time 前 72 小时。 + --end-time= 窗口结束时间,默认当前时间。 + --template=leave: 指定请假模板 ID,用于 template_id filter。 + --template=missed_punch: 指定补卡模板 ID,用于 template_id filter。 + --size=<1..100> 单页审批单号数量,默认 100。 + --help 显示帮助。 + +Secret 固定规则: + 审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。 + 不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。 + +隐私边界: + 报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。 + 不输出手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、表单值、附件、评论或企微原始 payload。 +`); +} + +function timestamp() { + return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); +} + +function toSafeString(value, maxLength = 500) { + if (value == null) return ""; + return String(value).trim().slice(0, maxLength); +} + +function safeMessage(value) { + return toSafeString(value, 500) + .replace(/access_token=[^&\s]+/gi, "access_token=***") + .replace(/[A-Za-z0-9_-]{24,}/g, "***") + .slice(0, 240); +} + +function supportedTargetKeys() { + return APPROVAL_POC_TARGETS.map((item) => item.key); +} + +function parseTargetPair(value, flagName) { + const raw = toSafeString(value, 500); + const separatorIndex = raw.indexOf(":"); + if (separatorIndex <= 0) { + throw new Error(`${flagName} requires :`); + } + const target = raw.slice(0, separatorIndex).trim(); + const itemValue = raw.slice(separatorIndex + 1).trim(); + if (!supportedTargetKeys().includes(target)) { + throw new Error(`${flagName} unsupported target: ${target}`); + } + if (!itemValue) { + throw new Error(`${flagName} requires non-empty value`); + } + return { target, value: itemValue }; +} + +function parseArgs(argv) { + const options = { + live: false, + format: "text", + reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-compensation-poc-${timestamp()}`), + startTime: "", + endTime: "", + templates: {}, + size: DEFAULT_PAGE_SIZE, + maxPages: DEFAULT_MAX_PAGES, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + options.help = true; + } else if (arg === "--live") { + options.live = true; + } else if (arg === "--no-live") { + options.live = false; + } else if (arg === "--format") { + options.format = argv[index + 1] || options.format; + index += 1; + } else if (arg.startsWith("--format=")) { + options.format = arg.slice("--format=".length); + } else if (arg === "--report-dir") { + options.reportDir = argv[index + 1] || options.reportDir; + index += 1; + } else if (arg.startsWith("--report-dir=")) { + options.reportDir = arg.slice("--report-dir=".length); + } else if (arg === "--start-time") { + options.startTime = argv[index + 1] || ""; + index += 1; + } else if (arg.startsWith("--start-time=")) { + options.startTime = arg.slice("--start-time=".length); + } else if (arg === "--end-time") { + options.endTime = argv[index + 1] || ""; + index += 1; + } else if (arg.startsWith("--end-time=")) { + options.endTime = arg.slice("--end-time=".length); + } else if (arg === "--template") { + const pair = parseTargetPair(argv[index + 1], "--template"); + options.templates[pair.target] = pair.value; + index += 1; + } else if (arg.startsWith("--template=")) { + const pair = parseTargetPair(arg.slice("--template=".length), "--template"); + options.templates[pair.target] = pair.value; + } else if (arg === "--size") { + options.size = Number(argv[index + 1] || options.size); + index += 1; + } else if (arg.startsWith("--size=")) { + options.size = Number(arg.slice("--size=".length)); + } else { + throw new Error(`Unsupported argument: ${arg}`); + } + } + + if (!["text", "json"].includes(options.format)) { + throw new Error(`Unsupported --format: ${options.format}`); + } + if (!Number.isFinite(options.size) || options.size < 1 || options.size > 100) { + throw new Error("--size must be between 1 and 100"); + } + options.size = Math.floor(options.size); + return options; +} + +function parseTimeInput(value) { + const raw = toSafeString(value, 80); + if (!raw) return null; + if (/^\d+$/.test(raw)) { + const numeric = Number(raw); + return numeric > 100000000000 ? Math.floor(numeric / 1000) : numeric; + } + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) { + throw new Error(`Invalid time value: ${raw}`); + } + return Math.floor(parsed / 1000); +} + +function buildTimeWindow(options, generatedAt) { + const generatedEnd = Math.floor(new Date(generatedAt).getTime() / 1000); + const endTime = parseTimeInput(options.endTime) || generatedEnd; + const startTime = parseTimeInput(options.startTime) || (endTime - DEFAULT_WINDOW_HOURS * 60 * 60); + if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || startTime <= 0 || endTime <= 0 || startTime >= endTime) { + throw new Error("Invalid approval compensation time window"); + } + return { + startTime, + endTime, + startIso: new Date(startTime * 1000).toISOString(), + endIso: new Date(endTime * 1000).toISOString(), + defaultWindowHours: DEFAULT_WINDOW_HOURS, + }; +} + +function envValue(env, name) { + return toSafeString(env[name], 500); +} + +function buildInputs(options, env = process.env) { + return APPROVAL_POC_TARGETS.map((target) => { + const cliTemplateId = toSafeString(options.templates[target.key], 500); + const envTemplateId = envValue(env, TEMPLATE_ENV[target.key]); + const templateId = cliTemplateId || envTemplateId; + return { + key: target.key, + label: target.label, + templateId, + templateIdPrefix: maskApprovalIdentifier(templateId), + templateSource: templateId ? (cliTemplateId ? "cli" : TEMPLATE_ENV[target.key]) : null, + templateMissingEnv: templateId ? [] : [TEMPLATE_ENV[target.key]], + }; + }); +} + +function buildSecretCheck(config) { + const missing = []; + if (!config.wecomCorpId) missing.push("WECOM_CORP_ID"); + if (!config.wecomAgentSecret) missing.push("WECOM_AGENT_SECRET"); + return { + id: "approval_compensation_secret_matrix", + source: "WECOM_AGENT_SECRET", + endpoints: [ + `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + "POST /cgi-bin/oa/getapprovaldetail", + ], + status: missing.length ? "BLOCKED" : "OK", + missing, + noFallback: [ + "WECOM_CONTACT_SECRET", + "WECOM_CHECKIN_SECRET", + "WECOM_HR_ASSISTANT_SECRET", + ], + }; +} + +function summarizeApiResponse(response) { + if (!response || typeof response !== "object") return null; + const summary = {}; + for (const key of [ + "errcode", + "errmsg", + "spNoCount", + "spNoPrefixes", + "nextCursorPresent", + "spStatus", + "applyFieldCount", + "approvalNodeCount", + "notifyerCount", + "commentCount", + ]) { + if (response[key] != null) { + summary[key] = key === "errmsg" ? safeMessage(response[key]) : response[key]; + } + } + return Object.keys(summary).length ? summary : null; +} + +function statusLabelFromSpStatus(value) { + const key = toSafeString(value, 20); + return { + "1": "审批中", + "2": "已通过", + "3": "已驳回", + "4": "已撤销", + "6": "通过后撤销", + "7": "已删除", + "10": "已支付", + }[key] || (key ? `状态${key}` : null); +} + +function summarizeDetailResult(target, spNo, result) { + const summary = result?.summary || {}; + const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40); + return { + target: target.key, + label: target.label, + status: result?.ok ? "OK" : "BLOCKED", + reason: result?.ok ? null : result?.code || "wecom_approval_detail_failed", + message: result?.message ? safeMessage(result.message) : null, + source: result?.tokenSource || "WECOM_AGENT_SECRET", + spNoPrefix: maskApprovalIdentifier(spNo), + templateIdPrefix: summary.templateIdPrefix || target.templateIdPrefix, + spStatus, + statusLabel: statusLabelFromSpStatus(spStatus), + applyFieldCount: summary.applyFieldCount, + approvalNodeCount: summary.approvalNodeCount, + notifyerCount: summary.notifyerCount, + commentCount: summary.commentCount, + response: summarizeApiResponse(result?.response), + }; +} + +function buildMissingTemplateCheck(input) { + return { + id: "approval_compensation_pull", + target: input.key, + label: input.label, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + source: "WECOM_AGENT_SECRET", + status: "NEEDS_CONFIG", + missingEnv: input.templateMissingEnv, + templateIdPrefix: null, + pageCount: 0, + spNoCount: 0, + details: [], + }; +} + +function buildDryRunTargetCheck(input) { + return { + id: "approval_compensation_pull", + target: input.key, + label: input.label, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + source: "WECOM_AGENT_SECRET", + status: "DRY_RUN_READY", + templateIdPrefix: input.templateIdPrefix, + configuredFrom: input.templateSource, + pageCount: 0, + spNoCount: 0, + details: [], + }; +} + +async function runLiveTarget({ + config, + input, + timeWindow, + options, + fetchApprovalInfoListImpl, + fetchApprovalDetailImpl, +}) { + const pages = []; + const spNos = []; + let cursor = ""; + let pageIndex = 0; + let blocked = null; + + do { + pageIndex += 1; + const result = await fetchApprovalInfoListImpl({ + config, + startTime: timeWindow.startTime, + endTime: timeWindow.endTime, + size: options.size, + nextCursor: cursor, + filters: [{ key: "template_id", value: input.templateId }], + }); + pages.push({ + page: pageIndex, + status: result.ok ? "OK" : "BLOCKED", + reason: result.ok ? null : result.code, + message: result.message ? safeMessage(result.message) : null, + source: result.tokenSource || "WECOM_AGENT_SECRET", + response: summarizeApiResponse(result.response), + spNoCount: result.spNos?.length || 0, + spNoPrefixes: (result.spNos || []).map(maskApprovalIdentifier), + nextCursorPresent: Boolean(result.nextCursor), + }); + if (!result.ok) { + blocked = result; + break; + } + spNos.push(...(result.spNos || [])); + const nextCursor = toSafeString(result.nextCursor, 160); + cursor = nextCursor && nextCursor !== cursor ? nextCursor : ""; + } while (cursor && pageIndex < options.maxPages); + + if (blocked) { + return { + id: "approval_compensation_pull", + target: input.key, + label: input.label, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + source: blocked.tokenSource || "WECOM_AGENT_SECRET", + status: "BLOCKED", + reason: blocked.code, + message: blocked.message ? safeMessage(blocked.message) : null, + templateIdPrefix: input.templateIdPrefix, + configuredFrom: input.templateSource, + pageCount: pages.length, + spNoCount: 0, + pages, + details: [], + }; + } + + const uniqueSpNos = Array.from(new Set(spNos)); + const details = []; + for (const spNo of uniqueSpNos) { + const detailResult = await fetchApprovalDetailImpl({ config, spNo }); + details.push(summarizeDetailResult(input, spNo, detailResult)); + } + + const detailBlocked = details.find((item) => item.status === "BLOCKED"); + return { + id: "approval_compensation_pull", + target: input.key, + label: input.label, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + source: "WECOM_AGENT_SECRET", + status: detailBlocked ? "BLOCKED" : "OK", + reason: detailBlocked ? detailBlocked.reason : null, + templateIdPrefix: input.templateIdPrefix, + configuredFrom: input.templateSource, + pageCount: pages.length, + spNoCount: uniqueSpNos.length, + pages, + details, + findings: cursor ? [{ id: "approval_compensation_pagination_truncated", status: "FINDING" }] : [], + }; +} + +async function runTargetChecks({ + config, + inputs, + timeWindow, + options, + fetchApprovalInfoListImpl, + fetchApprovalDetailImpl, +}) { + const targetChecks = []; + for (const input of inputs) { + if (!input.templateId) { + targetChecks.push(buildMissingTemplateCheck(input)); + } else if (!options.live) { + targetChecks.push(buildDryRunTargetCheck(input)); + } else { + targetChecks.push(await runLiveTarget({ + config, + input, + timeWindow, + options, + fetchApprovalInfoListImpl, + fetchApprovalDetailImpl, + })); + } + } + return targetChecks; +} + +function sanitizeEvidenceEvent(event) { + return { + callbackKind: toSafeString(event.callbackKind, 80), + approvalType: toSafeString(event.approvalType, 80), + spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40), + statusLabel: toSafeString(event.statusLabel, 80) || null, + spNoPrefix: toSafeString(event.spNoPrefix, 80) || null, + templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null, + recordedAt: toSafeString(event.recordedAt, 80) || null, + }; +} + +async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) { + const events = await readCallbackEvidenceImpl({ + evidenceFile: config.wecomApprovalCallbackEvidenceFile, + }); + const approvalEvents = (events || []) + .filter((item) => item?.callbackKind === "approval_status_change") + .map(sanitizeEvidenceEvent); + const bySpNoPrefix = {}; + for (const event of approvalEvents) { + if (!event.spNoPrefix) continue; + bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || []; + bySpNoPrefix[event.spNoPrefix].push(event); + } + return { + id: "callback_event_evidence", + status: approvalEvents.length ? "OK" : "PENDING", + eventCount: approvalEvents.length, + latestEvent: approvalEvents[approvalEvents.length - 1] || null, + bySpNoPrefix, + }; +} + +function collectPulledDetails(targetChecks) { + return targetChecks.flatMap((target) => ( + Array.isArray(target.details) + ? target.details.filter((item) => item.status === "OK") + : [] + )); +} + +function compareCallbackEvidence(targetChecks, evidenceCheck) { + const findings = []; + const pulledDetails = collectPulledDetails(targetChecks); + const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {}; + const pulledPrefixes = new Set(); + + for (const detail of pulledDetails) { + if (!detail.spNoPrefix) continue; + pulledPrefixes.add(detail.spNoPrefix); + const events = evidenceBySpNo[detail.spNoPrefix] || []; + if (!events.length) { + findings.push({ + id: "callback_missing_for_pulled_detail", + target: detail.target, + spNoPrefix: detail.spNoPrefix, + status: "FINDING", + }); + continue; + } + if (events.length > 1) { + findings.push({ + id: "callback_duplicate_for_pulled_detail", + target: detail.target, + spNoPrefix: detail.spNoPrefix, + count: events.length, + status: "FINDING", + }); + } + const hasStatusMatch = events.some((event) => ( + (event.spStatus && detail.spStatus && event.spStatus === detail.spStatus) + || (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel) + )); + if (!hasStatusMatch) { + findings.push({ + id: "callback_status_mismatch", + target: detail.target, + spNoPrefix: detail.spNoPrefix, + pulledStatus: detail.statusLabel || detail.spStatus, + callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null, + status: "FINDING", + }); + } + } + + for (const [spNoPrefix, events] of Object.entries(evidenceBySpNo)) { + if (!pulledPrefixes.has(spNoPrefix)) { + findings.push({ + id: "callback_not_in_compensation_window", + spNoPrefix, + count: events.length, + status: "FINDING", + }); + } + } + + return { + id: "callback_compensation_compare", + status: findings.length ? "FINDINGS" : "OK", + pulledDetailCount: pulledDetails.length, + callbackEventCount: evidenceCheck.eventCount, + findings, + }; +} + +function collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck) { + const gaps = []; + for (const check of targetChecks) { + if (check.status === "NEEDS_CONFIG") { + gaps.push({ + id: check.id, + target: check.target, + status: "NEEDS_CONFIG", + missingEnv: check.missingEnv || [], + }); + } + } + for (const gap of verificationGaps) { + gaps.push(gap); + } + const pulledCount = compareCheck?.pulledDetailCount || 0; + const hasTemplateConfigGap = targetChecks.some((item) => item.status === "NEEDS_CONFIG"); + if (pulledCount === 0 + && !verificationGaps.length + && !hasTemplateConfigGap + && !targetChecks.some((item) => item.status === "BLOCKED")) { + gaps.push({ + id: "approval_compensation_window_empty", + status: "NEEDS_CONFIG", + reason: "no_existing_oa_poc_approval_in_window", + }); + } + if (!verificationGaps.length && pulledCount > 0 && evidenceCheck.status !== "OK") { + gaps.push({ + id: "callback_evidence_missing", + status: "NEEDS_CONFIG", + reason: "no_wcbk1_callback_evidence_available_for_compare", + }); + } + return gaps; +} + +function collectMainBlockers(secretCheck, targetChecks) { + const blockers = []; + if (secretCheck.status === "BLOCKED") { + blockers.push({ + id: secretCheck.id, + reason: "missing_required_wecom_approval_config", + missing: secretCheck.missing, + }); + } + for (const check of targetChecks) { + if (check.status === "BLOCKED") { + blockers.push({ + id: check.id, + target: check.target, + reason: check.reason || "wecom_approval_compensation_api_blocked", + }); + } + } + return blockers; +} + +function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) { + if (mainBlockers.length) { + return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" }; + } + if (configGaps.length) { + return { + status: "PARTIAL_COMPENSATION_NEEDS_CONFIG", + conclusion: "PARTIAL_COMPENSATION_NEEDS_CONFIG", + }; + } + if (compareCheck?.findings?.length) { + return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" }; + } + return { status: "PASS_COMPENSATION_POC", conclusion: "PASS_COMPENSATION_POC" }; +} + +async function executeCompensationPoc(options, deps = {}) { + const env = deps.env || process.env; + const generatedAt = deps.generatedAt || new Date().toISOString(); + const config = deps.config || getConfig(); + const timeWindow = buildTimeWindow(options, generatedAt); + const inputs = buildInputs(options, env); + const secretCheck = buildSecretCheck(config); + const targetChecks = await runTargetChecks({ + config, + inputs, + timeWindow, + options, + fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList, + fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail, + }); + const evidenceCheck = await buildEvidenceCheck({ + config, + readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl, + }); + const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck); + const verificationGaps = options.live ? [] : [{ + id: "live_read", + status: "DRY_RUN", + reason: "run_with_--live_for_public_readonly_gate", + }]; + const mainBlockers = collectMainBlockers(secretCheck, targetChecks); + const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck); + const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck }); + + return { + mode: "qa_only", + generatedAt, + status: conclusion.status, + conclusion: conclusion.conclusion, + privacy: REPORT_PRIVACY, + scope: { + issue: "WACP3", + targets: APPROVAL_POC_TARGETS.map((item) => item.key), + writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission", + liveRead: Boolean(options.live), + deepMirror: "not_started", + nextStage: "WREAD1", + }, + timeWindow, + inputs: { + templates: Object.fromEntries(inputs.map((item) => [item.key, { + configured: Boolean(item.templateId), + configuredFrom: item.templateSource, + templateIdPrefix: item.templateIdPrefix, + }])), + pageSize: options.size, + maxPages: options.maxPages, + }, + checks: { + secretMatrix: secretCheck, + targets: targetChecks, + callbackEvidence: { + ...evidenceCheck, + bySpNoPrefix: undefined, + }, + callbackCompare: compareCheck, + }, + configGaps, + mainBlockers, + }; +} + +function ensureReportDir(reportDir) { + fs.mkdirSync(reportDir, { recursive: true }); +} + +function writeReport(reportDir, report) { + ensureReportDir(reportDir); + const filePath = path.join(reportDir, "summary.json"); + fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + return filePath; +} + +function printText(report, reportPath) { + process.stdout.write(`[wecom-approval-compensation-poc] status=${report.status} conclusion=${report.conclusion}\n`); + process.stdout.write(`[wecom-approval-compensation-poc] report=${reportPath}\n`); + process.stdout.write(`[wecom-approval-compensation-poc] privacy=${report.privacy}\n`); + process.stdout.write(`[wecom-approval-compensation-poc] liveRead=${report.scope.liveRead} targets=${report.scope.targets.join(",")}\n`); + if (report.configGaps?.length) { + process.stdout.write(`[wecom-approval-compensation-poc] configGaps=${JSON.stringify(report.configGaps)}\n`); + } + if (report.mainBlockers?.length) { + process.stdout.write(`[wecom-approval-compensation-poc] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const report = await executeCompensationPoc(options); + const reportPath = writeReport(options.reportDir, report); + if (options.format === "json") { + process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`); + } else { + printText(report, reportPath); + } +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write(`[wecom-approval-compensation-poc][FAIL] ${safeMessage(error?.message || error)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { + buildInputs, + buildSecretCheck, + buildTimeWindow, + compareCallbackEvidence, + evaluateConclusion, + executeCompensationPoc, + parseArgs, + statusLabelFromSpStatus, +}; diff --git a/server/src/services/integrations/wecomApproval.js b/server/src/services/integrations/wecomApproval.js index 6522e5ee..6b9c62cd 100644 --- a/server/src/services/integrations/wecomApproval.js +++ b/server/src/services/integrations/wecomApproval.js @@ -1,6 +1,7 @@ const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken"); const WECOM_API_BASE = "https://qyapi.weixin.qq.com"; +const APPROVAL_GET_INFO_ENDPOINT = "/cgi-bin/oa/getapprovalinfo"; const APPROVAL_CREATE_TEMPLATE_ENDPOINT = "/cgi-bin/oa/approval/create_template"; const APPROVAL_APPLY_EVENT_ENDPOINT = "/cgi-bin/oa/applyevent"; @@ -106,6 +107,32 @@ function summarizeApprovalPayload(payload) { }; } +function extractApprovalInfoSpNos(payload) { + if (!payload || typeof payload !== "object") return []; + const candidates = [ + payload.sp_no_list, + payload.spNoList, + payload.info?.sp_no_list, + ]; + const list = candidates.find((item) => Array.isArray(item)) || []; + return list.map((item) => toSafeString(item, 160)).filter(Boolean); +} + +function extractApprovalInfoNextCursor(payload) { + if (!payload || typeof payload !== "object") return ""; + return toSafeString(payload.next_cursor ?? payload.nextCursor ?? payload.info?.next_cursor, 160); +} + +function summarizeApprovalInfoPayload(payload) { + const spNos = extractApprovalInfoSpNos(payload); + const nextCursor = extractApprovalInfoNextCursor(payload); + return { + spNoCount: spNos.length, + spNoPrefixes: spNos.map(maskApprovalIdentifier), + nextCursorPresent: Boolean(nextCursor), + }; +} + function sanitizeApprovalApiResponse(payload) { if (!payload || typeof payload !== "object") return null; const result = sanitizeWecomResponse(payload) ?? {}; @@ -128,6 +155,12 @@ function sanitizeApprovalApiResponse(payload) { result.notifyerCount = summary.notifyerCount; result.commentCount = summary.commentCount; } + if (Array.isArray(payload.sp_no_list) || Array.isArray(payload.spNoList)) { + const summary = summarizeApprovalInfoPayload(payload); + result.spNoCount = summary.spNoCount; + result.spNoPrefixes = summary.spNoPrefixes; + result.nextCursorPresent = summary.nextCursorPresent; + } return Object.keys(result).length ? result : null; } @@ -341,6 +374,148 @@ async function fetchWecomApprovalDetail({ } } +function normalizeApprovalInfoFilter(filter) { + if (!filter || typeof filter !== "object") return null; + const key = toSafeString(filter.key, 80); + const value = toSafeString(filter.value, 200); + if (!key || !value) return null; + return { key, value }; +} + +function normalizeApprovalInfoRequest({ + startTime, + endTime, + size, + nextCursor, + filters, +}) { + const normalizedStart = Math.floor(Number(startTime)); + const normalizedEnd = Math.floor(Number(endTime)); + const normalizedSize = Math.max(1, Math.min(100, Math.floor(Number(size || 100)))); + + const body = { + starttime: normalizedStart, + endtime: normalizedEnd, + size: normalizedSize, + }; + const cursor = toSafeString(nextCursor, 160); + if (cursor) body.next_cursor = cursor; + + const normalizedFilters = Array.isArray(filters) + ? filters.map(normalizeApprovalInfoFilter).filter(Boolean) + : []; + if (normalizedFilters.length) { + body.filters = normalizedFilters; + } + return body; +} + +async function fetchWecomApprovalInfoList({ + config, + startTime, + endTime, + size = 100, + nextCursor = "", + filters = [], + fetchImpl = fetch, + timeoutMs = config?.wecomRequestTimeoutMs ?? 10000, +}) { + const normalizedStart = Math.floor(Number(startTime)); + const normalizedEnd = Math.floor(Number(endTime)); + if (!Number.isFinite(normalizedStart) || !Number.isFinite(normalizedEnd) || normalizedStart <= 0 || normalizedEnd <= 0) { + return { + ok: false, + skipped: true, + retryable: false, + code: "approval_compensation_window_invalid", + message: "Missing or invalid approval compensation time window", + tokenSource: "WECOM_AGENT_SECRET", + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + response: null, + spNos: [], + nextCursor: "", + summary: null, + }; + } + if (normalizedStart >= normalizedEnd) { + return { + ok: false, + skipped: true, + retryable: false, + code: "approval_compensation_window_invalid", + message: "Approval compensation startTime must be before endTime", + tokenSource: "WECOM_AGENT_SECRET", + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + response: null, + spNos: [], + nextCursor: "", + summary: null, + }; + } + + try { + const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs }); + const requestBody = normalizeApprovalInfoRequest({ + startTime: normalizedStart, + endTime: normalizedEnd, + size, + nextCursor, + filters, + }); + const result = await fetchWecomApprovalJson(APPROVAL_GET_INFO_ENDPOINT, token.accessToken, requestBody, { + fetchImpl, + timeoutMs, + }); + + if (!result.ok) { + const error = classifyWecomApprovalResult(result, "wecom_approval_info_failed"); + return { + ok: false, + skipped: false, + retryable: false, + code: error.code, + message: error.message, + tokenSource: token.tokenSource, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + response: error.response, + spNos: [], + nextCursor: "", + summary: null, + }; + } + + const spNos = extractApprovalInfoSpNos(result.payload); + const nextCursorValue = extractApprovalInfoNextCursor(result.payload); + return { + ok: true, + skipped: false, + retryable: false, + code: "wecom_approval_info_fetched", + message: null, + tokenSource: token.tokenSource, + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + response: sanitizeApprovalApiResponse(result.payload), + spNos, + nextCursor: nextCursorValue, + summary: summarizeApprovalInfoPayload(result.payload), + }; + } catch (error) { + return { + ok: false, + skipped: false, + retryable: Boolean(error?.details?.retryable), + code: error?.code || "wecom_approval_info_failed", + message: sanitizeErrorMessage(error?.message || error), + tokenSource: "WECOM_AGENT_SECRET", + endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`, + response: error?.details?.response || null, + spNos: [], + nextCursor: "", + summary: null, + }; + } +} + function extractTemplateId(payload) { return toSafeString(payload?.template_id ?? payload?.templateId, 160); } @@ -473,13 +648,16 @@ module.exports = { APPROVAL_POC_TARGETS, APPROVAL_APPLY_EVENT_ENDPOINT, APPROVAL_CREATE_TEMPLATE_ENDPOINT, + APPROVAL_GET_INFO_ENDPOINT, WecomApprovalPocError, fetchWecomApprovalApplyEvent, fetchWecomApprovalDetail, + fetchWecomApprovalInfoList, fetchWecomApprovalTemplateCreate, fetchWecomApprovalTemplateDetail, maskApprovalIdentifier, sanitizeApprovalApiResponse, + summarizeApprovalInfoPayload, summarizeApprovalPayload, summarizeTemplatePayload, }; diff --git a/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js b/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js new file mode 100644 index 00000000..ea646c36 --- /dev/null +++ b/server/tests/unit/scripts/check-wecom-approval-compensation-poc.test.js @@ -0,0 +1,417 @@ +const { + buildInputs, + buildSecretCheck, + buildTimeWindow, + compareCallbackEvidence, + executeCompensationPoc, + parseArgs, + statusLabelFromSpStatus, +} = require("../../../src/scripts/check-wecom-approval-compensation-poc"); +const { + fetchWecomApprovalInfoList, + maskApprovalIdentifier, +} = require("../../../src/services/integrations/wecomApproval"); +const { clearWecomAppAccessTokenCache } = require("../../../src/services/integrations/wecomAccessToken"); + +const SENSITIVE_AGENT_CREDENTIAL = ["compensation", "agent", "credential", "should", "never", "leak"].join("-"); +const SENSITIVE_CONTACT_CREDENTIAL = ["compensation", "contact", "credential", "unused"].join("-"); +const SENSITIVE_CHECKIN_CREDENTIAL = ["compensation", "checkin", "credential", "unused"].join("-"); +const SENSITIVE_HR_CREDENTIAL = ["compensation", "hr", "credential", "unused"].join("-"); +const SENSITIVE_TEMPLATE_ID = ["tpl", "compensation", "full", "identifier", "must", "not", "leak"].join("-"); +const SENSITIVE_SP_NO = ["sp", "compensation", "full", "identifier", "must", "not", "leak"].join("-"); +const SENSITIVE_USER_ID = ["compensation", "userid", "full", "must", "not", "leak"].join("_"); +const SENSITIVE_FORM_VALUE = "这里是审批表单值,报告不得输出"; + +function baseConfig(overrides = {}) { + return { + wecomCorpId: "corp-compensation", + wecomAgentId: "1000002", + wecomAgentSecret: SENSITIVE_AGENT_CREDENTIAL, + wecomContactSecret: SENSITIVE_CONTACT_CREDENTIAL, + wecomCheckinSecret: SENSITIVE_CHECKIN_CREDENTIAL, + wecomHrAssistantSecret: SENSITIVE_HR_CREDENTIAL, + wecomApprovalCallbackEvidenceFile: "/tmp/wacp3-test-evidence.jsonl", + wecomRequestTimeoutMs: 1000, + ...overrides, + }; +} + +function baseOptions(overrides = {}) { + return { + live: true, + format: "json", + reportDir: "/tmp/wecom-approval-compensation-test", + startTime: "2026-05-19T00:00:00.000Z", + endTime: "2026-05-20T00:00:00.000Z", + templates: { + leave: SENSITIVE_TEMPLATE_ID, + missed_punch: "tpl-missed-punch", + }, + size: 100, + maxPages: 10, + help: false, + ...overrides, + }; +} + +function jsonResponse(payload, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => payload, + }; +} + +function detailSummary(spNo = SENSITIVE_SP_NO, overrides = {}) { + return { + spNoPrefix: maskApprovalIdentifier(spNo), + templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), + spStatus: "2", + applyFieldCount: 3, + approvalNodeCount: 1, + notifyerCount: 1, + commentCount: 0, + ...overrides, + }; +} + +describe("check-wecom-approval-compensation-poc", () => { + beforeEach(() => { + clearWecomAppAccessTokenCache(); + }); + + it("parses CLI window, size and template filters", () => { + const options = parseArgs([ + "--live", + "--format=json", + "--start-time=2026-05-19T00:00:00.000Z", + "--end-time=2026-05-20T00:00:00.000Z", + "--size=50", + "--template=leave:tpl-leave", + "--template=missed_punch:tpl-missed", + ]); + + expect(options.live).toBe(true); + expect(options.format).toBe("json"); + expect(options.size).toBe(50); + expect(options.templates).toEqual({ + leave: "tpl-leave", + missed_punch: "tpl-missed", + }); + expect(() => parseArgs(["--template=outing:tpl"])).toThrow(/unsupported target/); + expect(() => parseArgs(["--size=101"])).toThrow(/--size/); + }); + + it("builds a default 72-hour time window", () => { + const window = buildTimeWindow(baseOptions({ startTime: "", endTime: "" }), "2026-05-20T00:00:00.000Z"); + + expect(window.endTime - window.startTime).toBe(72 * 60 * 60); + expect(window.defaultWindowHours).toBe(72); + }); + + it("builds inputs from CLI first, then env, with masked template previews", () => { + const inputs = buildInputs(baseOptions({ + templates: { leave: SENSITIVE_TEMPLATE_ID }, + }), { + WECOM_APPROVAL_LEAVE_TEMPLATE_ID: "env-should-not-win", + WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID: "env-missed-template", + }); + + expect(inputs.find((item) => item.key === "leave")).toMatchObject({ + templateSource: "cli", + templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), + }); + expect(inputs.find((item) => item.key === "missed_punch")).toMatchObject({ + templateSource: "WECOM_APPROVAL_MISSED_PUNCH_TEMPLATE_ID", + templateIdPrefix: maskApprovalIdentifier("env-missed-template"), + }); + }); + + it("documents WECOM_AGENT_SECRET as the only Secret source", () => { + const check = buildSecretCheck(baseConfig()); + expect(check).toMatchObject({ + status: "OK", + source: "WECOM_AGENT_SECRET", + endpoints: [ + "POST /cgi-bin/oa/getapprovalinfo", + "POST /cgi-bin/oa/getapprovaldetail", + ], + }); + expect(check.noFallback).toEqual([ + "WECOM_CONTACT_SECRET", + "WECOM_CHECKIN_SECRET", + "WECOM_HR_ASSISTANT_SECRET", + ]); + expect(buildSecretCheck(baseConfig({ wecomAgentSecret: "" }))).toMatchObject({ + status: "BLOCKED", + missing: ["WECOM_AGENT_SECRET"], + }); + }); + + it("calls getapprovalinfo with template filter and redacts sp_no list", async () => { + const fetchImpl = vi.fn(async (url, init) => { + const urlText = String(url); + if (urlText.includes("/cgi-bin/gettoken")) { + expect(urlText).toContain("corpsecret="); + return jsonResponse({ errcode: 0, errmsg: "ok", access_token: "token-compensation", expires_in: 7200 }); + } + expect(urlText).toContain("/cgi-bin/oa/getapprovalinfo"); + expect(urlText).not.toContain(SENSITIVE_AGENT_CREDENTIAL); + const body = JSON.parse(init.body); + expect(body).toMatchObject({ + starttime: 1779148800, + endtime: 1779235200, + size: 50, + filters: [{ key: "template_id", value: SENSITIVE_TEMPLATE_ID }], + }); + return jsonResponse({ + errcode: 0, + errmsg: "ok", + sp_no_list: [SENSITIVE_SP_NO], + next_cursor: "", + }); + }); + + const result = await fetchWecomApprovalInfoList({ + config: baseConfig(), + startTime: 1779148800, + endTime: 1779235200, + size: 50, + filters: [{ key: "template_id", value: SENSITIVE_TEMPLATE_ID }], + fetchImpl, + }); + + expect(result.ok).toBe(true); + expect(result.spNos).toEqual([SENSITIVE_SP_NO]); + expect(result.response.spNoPrefixes).toEqual([maskApprovalIdentifier(SENSITIVE_SP_NO)]); + expect(JSON.stringify(result.response)).not.toContain(SENSITIVE_SP_NO); + }); + + it("returns PARTIAL_COMPENSATION_NEEDS_CONFIG when template filters are missing", async () => { + const report = await executeCompensationPoc(baseOptions({ + templates: {}, + }), { + config: baseConfig(), + env: {}, + generatedAt: "2026-05-20T00:00:00.000Z", + fetchApprovalInfoListImpl: vi.fn(), + fetchApprovalDetailImpl: vi.fn(), + readCallbackEvidenceImpl: vi.fn(async () => []), + }); + + expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG"); + expect(report.configGaps.map((item) => item.id)).toContain("approval_compensation_pull"); + expect(report.configGaps.map((item) => item.id)).not.toContain("approval_compensation_window_empty"); + expect(report.checks.targets.every((item) => item.status === "NEEDS_CONFIG")).toBe(true); + }); + + it("returns PARTIAL_COMPENSATION_NEEDS_CONFIG in dry-run even when templates are configured", async () => { + const report = await executeCompensationPoc(baseOptions({ live: false }), { + config: baseConfig(), + env: {}, + generatedAt: "2026-05-20T00:00:00.000Z", + readCallbackEvidenceImpl: vi.fn(async () => []), + }); + + expect(report.status).toBe("PARTIAL_COMPENSATION_NEEDS_CONFIG"); + expect(report.configGaps.map((item) => item.id)).toContain("live_read"); + expect(report.checks.targets.every((item) => item.status === "DRY_RUN_READY")).toBe(true); + }); + + it("pulls approval numbers and details, then passes when callback evidence matches", async () => { + const fetchApprovalInfoListImpl = vi.fn(async ({ filters }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + response: { + errcode: 0, + spNoCount: 1, + spNoPrefixes: [maskApprovalIdentifier(`${filters[0].value}-sp`)], + }, + spNos: [`${filters[0].value}-sp`], + nextCursor: "", + })); + const fetchApprovalDetailImpl = vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: detailSummary(spNo, { + templateIdPrefix: spNo.startsWith(SENSITIVE_TEMPLATE_ID) + ? maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID) + : maskApprovalIdentifier("tpl-missed-punch"), + }), + response: { + errcode: 0, + spStatus: "2", + applyFieldCount: 3, + approvalNodeCount: 1, + commentCount: 0, + }, + })); + const evidence = [ + { + callbackKind: "approval_status_change", + approvalType: "leave", + spStatus: "2", + statusLabel: "已通过", + spNoPrefix: maskApprovalIdentifier(`${SENSITIVE_TEMPLATE_ID}-sp`), + templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), + }, + { + callbackKind: "approval_status_change", + approvalType: "missed_punch", + spStatus: "2", + statusLabel: "已通过", + spNoPrefix: maskApprovalIdentifier("tpl-missed-punch-sp"), + templateIdPrefix: maskApprovalIdentifier("tpl-missed-punch"), + }, + ]; + + const report = await executeCompensationPoc(baseOptions(), { + config: baseConfig(), + env: {}, + generatedAt: "2026-05-20T00:00:00.000Z", + fetchApprovalInfoListImpl, + fetchApprovalDetailImpl, + readCallbackEvidenceImpl: vi.fn(async () => evidence), + }); + + expect(report.status).toBe("PASS_COMPENSATION_POC"); + expect(fetchApprovalInfoListImpl).toHaveBeenCalledTimes(2); + expect(fetchApprovalDetailImpl).toHaveBeenCalledTimes(2); + expect(report.checks.callbackCompare.findings).toEqual([]); + }); + + it("returns PASS_WITH_FINDINGS for missing callback and status mismatch", async () => { + const report = await executeCompensationPoc(baseOptions(), { + config: baseConfig(), + env: {}, + generatedAt: "2026-05-20T00:00:00.000Z", + fetchApprovalInfoListImpl: vi.fn(async () => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + response: { errcode: 0, spNoCount: 1 }, + spNos: [SENSITIVE_SP_NO], + nextCursor: "", + })), + fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: detailSummary(spNo, { spStatus: "2" }), + response: { errcode: 0, spStatus: "2" }, + })), + readCallbackEvidenceImpl: vi.fn(async () => [{ + callbackKind: "approval_status_change", + spStatus: "3", + statusLabel: "已驳回", + spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO), + }]), + }); + + expect(report.status).toBe("PASS_WITH_FINDINGS"); + expect(report.checks.callbackCompare.findings.map((item) => item.id)).toContain("callback_status_mismatch"); + }); + + it("classifies WeCom API errors as BLOCKED_BY_EXTERNAL_CONFIG", async () => { + const report = await executeCompensationPoc(baseOptions({ + templates: { leave: SENSITIVE_TEMPLATE_ID }, + }), { + config: baseConfig(), + env: {}, + generatedAt: "2026-05-20T00:00:00.000Z", + fetchApprovalInfoListImpl: vi.fn(async () => ({ + ok: false, + tokenSource: "WECOM_AGENT_SECRET", + code: "wecom_approval_api_error", + message: "WeCom approval API errcode 48002", + response: { errcode: 48002, errmsg: "api forbidden" }, + spNos: [], + })), + fetchApprovalDetailImpl: vi.fn(), + readCallbackEvidenceImpl: vi.fn(async () => []), + }); + + expect(report.status).toBe("SKIP"); + expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG"); + expect(report.mainBlockers[0]).toMatchObject({ + reason: "wecom_approval_api_error", + }); + }); + + it("compares callback evidence using redacted prefixes", () => { + const compare = compareCallbackEvidence([ + { + details: [{ + target: "leave", + spNoPrefix: "SPMATCH***", + spStatus: "2", + statusLabel: "已通过", + status: "OK", + }], + }, + ], { + eventCount: 2, + bySpNoPrefix: { + "SPMATCH***": [ + { spNoPrefix: "SPMATCH***", spStatus: "2", statusLabel: "已通过" }, + { spNoPrefix: "SPMATCH***", spStatus: "2", statusLabel: "已通过" }, + ], + "SPONLYCB***": [ + { spNoPrefix: "SPONLYCB***", spStatus: "2", statusLabel: "已通过" }, + ], + }, + }); + + expect(compare.status).toBe("FINDINGS"); + expect(compare.findings.map((item) => item.id)).toEqual(expect.arrayContaining([ + "callback_duplicate_for_pulled_detail", + "callback_not_in_compensation_window", + ])); + }); + + it("redacts secrets, full identifiers and form values from reports", async () => { + const report = await executeCompensationPoc(baseOptions({ + templates: { leave: SENSITIVE_TEMPLATE_ID }, + }), { + config: baseConfig(), + env: { + WECOM_AGENT_SECRET: SENSITIVE_AGENT_CREDENTIAL, + }, + generatedAt: "2026-05-20T00:00:00.000Z", + fetchApprovalInfoListImpl: vi.fn(async () => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + response: { errcode: 0, spNoCount: 1, spNoPrefixes: [maskApprovalIdentifier(SENSITIVE_SP_NO)] }, + spNos: [SENSITIVE_SP_NO], + nextCursor: "", + })), + fetchApprovalDetailImpl: vi.fn(async ({ spNo }) => ({ + ok: true, + tokenSource: "WECOM_AGENT_SECRET", + summary: detailSummary(spNo), + response: { + errcode: 0, + spStatus: "2", + applyFieldCount: 3, + approvalNodeCount: 1, + }, + })), + readCallbackEvidenceImpl: vi.fn(async () => [{ + callbackKind: "approval_status_change", + spStatus: "2", + statusLabel: "已通过", + spNoPrefix: maskApprovalIdentifier(SENSITIVE_SP_NO), + templateIdPrefix: maskApprovalIdentifier(SENSITIVE_TEMPLATE_ID), + }]), + }); + const serialized = JSON.stringify(report); + + expect(serialized).not.toContain(SENSITIVE_AGENT_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_CONTACT_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_CHECKIN_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_HR_CREDENTIAL); + expect(serialized).not.toContain(SENSITIVE_SP_NO); + expect(serialized).not.toContain(SENSITIVE_TEMPLATE_ID); + expect(serialized).not.toContain(SENSITIVE_USER_ID); + expect(serialized).not.toContain(SENSITIVE_FORM_VALUE); + expect(statusLabelFromSpStatus("2")).toBe("已通过"); + }); +});