WACP3 实现企微审批补偿拉取 POC
这个提交包含在:
文件差异因一行或多行过长而隐藏
@@ -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` |
|
||||
|
||||
## 当前门禁
|
||||
|
||||
|
||||
+4
-4
@@ -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 不可用时仍可查看已保存历史,再进入完整镜像增强。
|
||||
|
||||
+5
-4
@@ -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。 |
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
+2
@@ -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,旧文件保留历史审计语义。
|
||||
|
||||
|
||||
+1
-1
@@ -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` 工作区模式。
|
||||
|
||||
+2
-2
@@ -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` |
|
||||
|
||||
+2
-2
@@ -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 最小自建历史留存实现`:先只读保存企微审批、打卡和人事关键快照,保证企微不可用时可查看历史,再进入完整镜像增强。
|
||||
|
||||
@@ -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:<id>、--template=missed_punch:<id>、--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/企微写入。"
|
||||
|
@@ -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",
|
||||
|
||||
@@ -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=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-compensation-poc-<timestamp>。
|
||||
--start-time=<iso|epoch> 窗口开始时间,默认 end-time 前 72 小时。
|
||||
--end-time=<iso|epoch> 窗口结束时间,默认当前时间。
|
||||
--template=leave:<id> 指定请假模板 ID,用于 template_id filter。
|
||||
--template=missed_punch:<id> 指定补卡模板 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 <target>:<value>`);
|
||||
}
|
||||
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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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("已通过");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户