diff --git a/docs/trialrun/人工试运行报告.md b/docs/trialrun/人工试运行报告.md index 18e65db0..b75c1bc1 100644 --- a/docs/trialrun/人工试运行报告.md +++ b/docs/trialrun/人工试运行报告.md @@ -38,6 +38,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 - 2026-05-17 LAUNCH-AUTO-OPS-V1 已完成正式启用后自动化收口脚本和证据刷新:本地 Prisma/node check/focused 单测、前端 typecheck/build、公网 readiness、PDF live OCR、统一考勤 live 检查、公网移动矩阵和企业微信 UA/渲染矩阵均通过;显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`;但只读 flag 检查发现当前公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,结论为 `PAUSE_FOR_FIX`。runner 已跳过付款批次写入,未切换 flag、未部署、不接真实支付、不接 ERP。 - 2026-05-17 AUTO-REMAINING-CLOSEOUT 已完成剩余无需人工操作收口:受控恢复公网 `payment_batch_v1=force_on`,保持 `erp_sync=force_off`;复跑 `run-launch-auto-ops.sh` 通过,公网测试报销 `BX-20260517-0027` 完成审批、付款批次创建/复核、回单上传/下载、付款执行、AP 导出和审计核验;公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed。显式企微打卡同步仍为 `SKIP wecom_checkin_sync_disabled`,属于外部后台配置缺口,不阻断 OA 统一考勤入口和正式运营门禁;结论 `GO_TO_OPERATE`。 - 2026-05-17 HR-WECOM-DIRECTORY-V1 已完成并部署公网:产品口径已调整为 ERP 未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现。OA 继续作为员工、权限、审批和审计主事实源;企业微信作为通讯录、入口、通知和打卡采集基础设施。HRWD 新增企业微信通讯录同步 flag、配置、同步记录、入职创建/资料更新/离职禁用钩子和员工管理重试入口。本地后端/前端/Playwright 通过,部署后公网 readiness、PDF live OCR、HRWD 端点 SKIP 检查和公网移动/企微矩阵通过;部署过程中发现 `seed-config` 会覆盖正式 feature flag,已修正为只创建缺失开关并复部署验证,最终 `payment_batch_v1=force_on`、`erp_sync=force_off`。 +- 2026-05-17 HRWD-CONTACT-LIVE-TEST 已完成公网企业微信通讯录真实写入闭环:使用 `HRWDTEST-20260517214548` 测试员工,验证 OA 创建员工后由 onboarding 触发企微 create、资料更新触发企微 update、离职 checklist 完成后禁用 OA 账号并触发企微 disable;三次企微同步均为 `wecom_contact_synced` 且 `errcode=0`。普通员工查看/重试他人同步状态被拒绝,审计日志包含 `WECOM_DIRECTORY_CREATE/UPDATE/DISABLE`;测试成员按约定保留离职/禁用状态,不删除。 ## 关键证据索引 @@ -83,6 +84,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | LAUNCH-AUTO-OPS-V1 | 阶段收口:自动化证据完成,结论 `PAUSE_FOR_FIX`;除付款批次正式 flag 漂移外,本地门禁、公网 readiness/PDF live OCR、统一考勤、公网移动和企业微信 UA/渲染矩阵均通过;付款批次写入因前置失败被跳过 | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-175548/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-175755/summary.json` | | AUTO-REMAINING-CLOSEOUT | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE`;公网 `payment_batch_v1=force_on`、`erp_sync=force_off`,付款批次常开闭环、AP 导出、审计、公网移动和企业微信渲染矩阵均通过;企微打卡同步外部配置仍记 `SKIP` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/summary.json`、`artifacts/qa-flow/launch-auto-ops-20260517-193317/live-result.json` | | HR-WECOM-DIRECTORY-V1 | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网;ERP 改为未来不接入 OA;员工创建/入职/调岗/资料变更/离职可触发企微通讯录创建、更新、禁用并记录同步状态;缺外部配置时公网重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事流程;部署 seed 已修正为不覆盖正式 flag | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv`、`server/tests/unit/routes/employees.wecomDirectory.test.js`、`artifacts/qa-flow/live-smoke-readiness-20260517-205813/summary.json`、`docs/需求-企业微信基础设施规约.md` | +| HRWD-CONTACT-LIVE-TEST | 已收口:公网企微通讯录真实写入闭环通过;测试员工 `HRWDTEST-20260517214548` 完成 create/update/disable,企微 `errcode=0`,OA 最终 archived/inactive,离职后登录负例、普通员工权限负例和审计动作均通过;最终保留离职/禁用,不删除成员 | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv`、`artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json`、`artifacts/qa-flow/live-smoke-readiness-20260517-214632/summary.json` | ## 当前门禁 @@ -117,6 +119,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开 | ATTV1 考勤统一入口 | 通过,已部署 | 部署提交 `916cc3b` + 移动入口修正 `b2b4447`;历史 OA 自建打卡关闭,补卡、外出、加班已进入 OA 统一审批和月度解释;公网 `/ready`、live smoke、PDF live OCR、移动/企业微信矩阵 14 passed;企微 live 同步缺配置时按 `SKIP` 记录。 | | LAUNCH-AUTO-OPS-V1 正式启用后自动化收口 | 暂停,结论 `PAUSE_FOR_FIX` | `run-launch-auto-ops.sh` 报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/`;本地门禁、live readiness、PDF live OCR、考勤统一入口、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;只读 flag 检查失败:`payment_batch_v1=force_off`,期望为正式常开 `force_on`,因此付款批次写入回归被安全跳过。 | | AUTO-REMAINING-CLOSEOUT 剩余自动化收口 | 通过,结论 `GO_TO_OPERATE` | 新增 QA-only flag 恢复脚本并受控恢复 `payment_batch_v1=force_on`;`run-launch-auto-ops.sh` 报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/`,本地门禁、live readiness、PDF live OCR、正式 flag、统一考勤、付款批次常开闭环、公网移动矩阵 6 passed、企业微信渲染矩阵 8 passed;测试报销 `BX-20260517-0027` paid,票据 archived,AP 台账 paid;企微打卡同步缺后台配置按 `SKIP wecom_checkin_sync_disabled` 记录。 | +| HRWD-CONTACT-LIVE-TEST 企微通讯录公网闭环 | 通过,结论 `PASS` | 新增 QA-only runner `scripts/qa/run-hrwd-contact-live-test.mjs`;公网创建 `HRWDTEST-20260517214548` 测试员工,onboarding 触发企微 create、资料变更触发 update、offboarding checklist 触发 disable,三次同步均 `errcode=0`;OA 账号最终 archived/inactive,离职后登录 403 `AUTH_003`,普通员工同步状态权限负例和审计动作均通过;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json`。 | ## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进 diff --git a/docs/文档事实源状态矩阵.md b/docs/文档事实源状态矩阵.md index 9085e224..22994d3b 100644 --- a/docs/文档事实源状态矩阵.md +++ b/docs/文档事实源状态矩阵.md @@ -59,6 +59,7 @@ | `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | `EVIDENCE` | 正式启用后自动化收口已阶段完成,结论 `PAUSE_FOR_FIX`:本地门禁、公网 readiness/PDF live OCR、考勤统一入口、移动矩阵和企业微信 UA/渲染矩阵通过;企微打卡同步显式检查为 `SKIP wecom_checkin_sync_disabled`;公网 `payment_batch_v1=force_off` 与正式常开口径不一致,付款批次写入已安全跳过 | | `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | `EVIDENCE` | 剩余无需人工操作事项已收口,结论 `GO_TO_OPERATE`:公网 `payment_batch_v1` 已恢复 `force_on`,`erp_sync=force_off`;正式自动巡检报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` 为 PASS,付款批次常开闭环、移动矩阵和企业微信渲染矩阵通过;企微打卡同步仍因外部配置 `SKIP wecom_checkin_sync_disabled` | | `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | `EVIDENCE` | 人事生命周期与企业微信通讯录同步已部署并收口:ERP 未来不接入 OA;HR 大域拆分为人事生命周期和考勤两个模块;OA 驱动企微通讯录创建/更新/禁用,失败不回滚 OA 人事动作;公网端点检查显示缺通讯录配置时重试记录 `SKIP wecom_contact_sync_disabled`,不阻断 OA 人事闭环;seed-config 已改为不覆盖已有正式 feature flag,复部署后 `payment_batch_v1=force_on`、`erp_sync=force_off` | +| `HRWD-CONTACT-LIVE-TEST` | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv` | `EVIDENCE` | 公网企微通讯录真实写入闭环已收口:`HRWDTEST-20260517214548` 测试员工完成 create/update/disable,企微 `errcode=0`,OA 员工最终 archived/inactive,登录负例、普通员工权限负例和审计动作均通过;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json` | 旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。 @@ -172,4 +173,4 @@ - `ME6-3` 代表性企业微信真机门禁已关闭。 - MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。 -- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口和 AUTO-REMAINING-CLOSEOUT 剩余自动化收口均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。HRWD 当前补齐 OA 驱动企业微信通讯录同步,live 写入同样依赖通讯录 Secret、默认部门和 API 权限。 +- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障、REQ-COMPLETE-AUDIT 需求完成度复核、ATTV1 考勤统一入口、AUTO-REMAINING-CLOSEOUT 剩余自动化收口和 HRWD-CONTACT-LIVE-TEST 公网企微通讯录真实写入闭环均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同。LAUNCH-AUTO-OPS-V1 发现的公网 `payment_batch_v1=force_off` 漂移已由 AUTOREM 恢复为 `force_on`,正式自动巡检结论为 `GO_TO_OPERATE`;`erp_sync=force_off`,ERP 未来不接入 OA,正式支付接口、图片 OCR 和银行流水导入继续不进入当前路线。ATTV1 已部署:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。HRWD 已完成 OA 驱动企业微信通讯录同步,并通过 `HRWDTEST-20260517214548` 真实公网 create/update/disable 验证;测试成员按审计要求保留离职/禁用状态。 diff --git a/docs/文档索引.md b/docs/文档索引.md index 821f392a..81c3b9fc 100644 --- a/docs/文档索引.md +++ b/docs/文档索引.md @@ -20,7 +20,7 @@ ## 最近事实源链 -R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1。 +R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST。 - R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。 - ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。 @@ -53,6 +53,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M - LAUNCH-AUTO-OPS-V1 是 ATTV1 后的正式启用自动化收口合同:新增当前正式状态 QA-only runner、live feature flag 只读检查和统一考勤 live 检查;本地门禁、公网 readiness、PDF live OCR、统一考勤检查、移动矩阵和企业微信 UA/渲染矩阵均通过。显式企微打卡同步检查返回 `SKIP wecom_checkin_sync_disabled`。实际公网 `payment_batch_v1=force_off`,与 FIN-PROD-ENABLE 记录的正式常开口径不一致,因此结论为 `PAUSE_FOR_FIX`;runner 已跳过付款批次写入,未切换 flag、未部署、未接真实支付或 ERP。 - AUTO-REMAINING-CLOSEOUT 是 LAUNCH-AUTO-OPS-V1 后的剩余自动化收口合同:已受控恢复公网 `payment_batch_v1=force_on` 并保持 `erp_sync=force_off`,复跑正式启用后自动巡检通过;`LAUNCHAUTO` marker 测试报销 `BX-20260517-0027` 完成付款批次、回单、AP 导出和审计闭环;公网移动矩阵 6 passed,企业微信渲染矩阵 8 passed。企微打卡 live 同步仍因外部配置记录 `SKIP wecom_checkin_sync_disabled`,不影响 OA 统一考勤入口完成态。 - HR-WECOM-DIRECTORY-V1 是 HRWD 当前合同:ERP 从冻结待办改为未来不接入 OA;人事生命周期和考勤同属 HR 大域但分模块实现;OA 为员工、权限、审批和审计主事实源,企业微信作为通讯录、入口、通知和打卡采集基础设施。新增 `hr_wecom_directory_sync_v1`、企业微信通讯录同步记录、入职创建/调岗更新/离职禁用和管理员重试;缺通讯录 Secret 或 API 权限时 OA 人事闭环照常完成,同步记录为可解释 SKIP/失败。 +- HRWD-CONTACT-LIVE-TEST 已完成公网企微通讯录真实写入闭环:使用 `HRWDTEST-20260517214548` 测试员工验证 OA 创建员工后由 onboarding 触发企微成员创建、资料更新触发企微 update、离职 checklist 完成后禁用 OA 账号并触发企微 disable;同步均返回 `wecom_contact_synced`、`errcode=0`,普通员工同步记录权限负例和审计动作核验通过。测试成员按约定保留离职/禁用状态,不删除。 ## 当前活跃治理流 @@ -92,6 +93,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M | `LAUNCH-AUTO-OPS-V1` | `issues/2026-05-17_17-47-45-launch-auto-ops-v1.csv` | 阶段收口:自动化完成,结论 `PAUSE_FOR_FIX` | 正式启用后自动 runner、live flag 只读校验、考勤统一入口 live 检查、移动/企微矩阵和证据回填;报告 `artifacts/qa-flow/launch-auto-ops-20260517-175548/` | 不切换正式 flag、不部署、不接真实支付、不接 ERP;`payment_batch_v1` 当前 force_off 漂移需单独决策恢复 | | `AUTO-REMAINING-CLOSEOUT` | `issues/2026-05-17_19-30-44-auto-remaining-closeout.csv` | 已收口:剩余自动化运营门禁通过,结论 `GO_TO_OPERATE` | 恢复正式 `payment_batch_v1=force_on`、复跑 `run-launch-auto-ops.sh`、付款批次常开闭环、移动/企微矩阵、企微同步 SKIP 证据;报告 `artifacts/qa-flow/launch-auto-ops-20260517-193317/` | 不部署、不改业务代码、不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水;企微打卡同步外部配置仍需后台配置 | | `HR-WECOM-DIRECTORY-V1` | `issues/2026-05-17_20-00-40-hr-wecom-directory-v1.csv` | 已收口:人事生命周期与企业微信通讯录同步闭环已部署公网 | ERP 永不接入 OA;入职/调岗/资料变更/离职驱动企微通讯录创建、更新、禁用;员工管理和详情展示同步状态与重试;部署后 live smoke、HRWD 端点 SKIP 检查、公网移动/企微矩阵通过;seed 已改为不覆盖正式 flag | 不使用企业微信原生审批、不接真实支付、不开放图片 OCR、不导入银行流水;企微通讯录真实写入依赖后台 Secret、默认部门和 API 权限 | +| `HRWD-CONTACT-LIVE-TEST` | `issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv` | 已收口:公网企微通讯录真实写入闭环通过 | `HRWDTEST-20260517214548` 测试员工完成 OA 创建、onboarding 企微 create、资料 update、offboarding 企微 disable、OA 禁用、登录负例、权限负例和审计核验;报告 `artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json` | 不删除企业微信测试成员、不重置数据库、不改业务 API/schema/UI、不部署;测试成员最终保留离职/禁用状态 | 旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。 diff --git a/issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv b/issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv new file mode 100644 index 00000000..449c426e --- /dev/null +++ b/issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv @@ -0,0 +1,9 @@ +"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes" +"HCLT-0","合同建档","记录用户已批准创建并禁用一个带 HRWDTEST 标记的公网 OA 测试员工和企业微信测试成员;本轮只做 QA-only runner、公网闭环验证和事实源回填,不改业务 API/schema/UI。","1) Issue CSV 校验通过;2) 范围明确为 HRWDTEST 创建-更新-禁用闭环;3) 明确不删除企微成员、不打印 secret/token/cookie/完整手机号;4) 不涉及 ERP、真实支付、图片 OCR、银行流水导入、生产恢复或数据库重置。","受影响流程:HRWD 公网验证合同 -> 后续 QA runner;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv","[]","回滚路径:回退本 CSV 和文档回填;运行期测试员工不删除,按离职/禁用保留审计链。" +"HCLT-1","公网前置核验","核验 /ready、live smoke、正式 feature flags 和企业微信入口状态,确认 employee_lifecycle_v1、hr_wecom_directory_sync_v1、payment_batch_v1、erp_sync 均符合正式口径。","1) live smoke env strict 通过;2) feature flag 检查通过:payment_batch_v1=force_on、erp_sync=force_off、employee_lifecycle_v1=enabled、hr_wecom_directory_sync_v1=force_on;3) 输出不包含 secret、token、cookie 或完整手机号。","受影响流程:公网前置 -> HRWD 通讯录写入安全门;验证步骤:set -a && source ~/.hyb-oa/live-smoke.env && set +a;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-smoke-env.mjs --strict;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/check-live-feature-flags.mjs --expect payment_batch_v1=force_on --expect erp_sync=force_off --expect employee_lifecycle_v1=enabled --expect hr_wecom_directory_sync_v1=force_on。","node|bash","DONE","DONE","DONE","scripts/qa/check-live-smoke-env.mjs|scripts/qa/check-live-feature-flags.mjs","[""HCLT-0""]","公网只读前置;失败则不创建测试员工。" +"HCLT-2","QA-only runner","新增 scripts/qa/run-hrwd-contact-live-test.mjs,要求 HRWD_CONTACT_LIVE_APPROVED=1 才允许公网写入,统一使用 live smoke 账号、HRWDTEST marker 和脱敏输出。","1) 缺 HRWD_CONTACT_LIVE_APPROVED=1 时 runner 拒绝写入;2) runner 不打印一次性密码、完整手机号、cookie、token、secret 或企微原始 payload;3) node --check 通过;4) 仅新增 QA-only 脚本。","受影响流程:QA runner -> 管理员登录 -> 测试员工闭环;验证步骤:node --check scripts/qa/run-hrwd-contact-live-test.mjs;git diff --check。","node|bash","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-0""]","如脚本自身阻断可最小修复;若发现业务代码缺陷,停止并另建 hotfix Issue。" +"HCLT-3","入职创建","管理员创建 HRWDTEST 测试员工,payload 使用 syncWecomDirectory=false;随后发起 onboarding,由 onboarding 钩子触发企业微信成员创建。","1) OA 测试员工创建成功并返回脱敏员工 ID;2) onboarding 后企业微信通讯录 create 同步 success;3) 企微 errcode=0;4) 创建失败时自动换测试手机号重试一次;5) 创建成功后不删除测试员工。","受影响流程:管理员 -> 创建员工 -> 入职启动 -> 企微通讯录创建 -> 绑定;验证步骤:HRWD_CONTACT_LIVE_APPROVED=1 QA_ISSUE_ID=HCLT LIVE_SMOKE_BASE_URL=https://oa.hyinda.com node scripts/qa/run-hrwd-contact-live-test.mjs。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-1"",""HCLT-2""]","测试数据 marker:HRWDTEST;手机号 199 开头,只输出 mask。" +"HCLT-4","资料更新","修改 HRWDTEST 测试员工职位字段,验证企业微信通讯录 update 成功。","1) OA profile patch 成功;2) 企微 update 同步 success;3) 企微 errcode=0;4) 同步记录可由管理员读取。","受影响流程:管理员 -> 修改员工资料 -> 企微通讯录更新 -> 同步记录;验证步骤:同 HCLT-3 runner 输出 update 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-3""]","仅修改 HRWDTEST 测试员工;不触碰真实员工。" +"HCLT-5","离职禁用","发起 HRWDTEST 测试员工离职并完成 checklist,验证 OA 账号归档/禁用、会话失效、企业微信成员 disable 成功。","1) offboarding start 成功;2) checklist 完成后 OA 员工 status=archived 或 employmentStatus=inactive;3) 企微 disable 同步 success 且 errcode=0;4) 离职后使用一次性密码登录失败;5) 企业微信成员保留禁用不删除。","受影响流程:管理员 -> 离职启动 -> checklist -> OA 账号禁用 -> 企微成员禁用 -> 登录负例;验证步骤:同 HCLT-3 runner 输出 disable 和 login-negative 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-4""]","若禁用失败,停止并记录 PAUSE_FOR_FIX;不删除企微成员。" +"HCLT-6","权限审计","验证普通员工不可查看/重试他人同步状态,管理员可查看同步记录,审计包含创建、更新、禁用或重试相关动作。","1) 普通员工读取 HRWDTEST 同步状态返回 403/权限拒绝;2) 管理员可读取同步记录;3) 审计日志能查到 WECOM_DIRECTORY_CREATE、WECOM_DIRECTORY_UPDATE、WECOM_DIRECTORY_DISABLE;4) 若审计接口不可用则明确 FAIL,不伪造通过。","受影响流程:权限边界 -> 同步记录 -> 审计日志;验证步骤:同 HCLT-3 runner 输出 permission 和 audit 阶段 PASS。","node|fetch","DONE","DONE","DONE","scripts/qa/run-hrwd-contact-live-test.mjs","[""HCLT-5""]","审计输出只记录动作和数量,不打印 extra 中的敏感字段。" +"HCLT-7","收口回填","回填 CSV、人工试运行报告和事实源状态,运行公网 readiness,中文提交。","1) Issue CSV 按实测回填 DONE/阻断;2) docs/trialrun/人工试运行报告.md、docs/文档索引.md、docs/文档事实源状态矩阵.md 已记录 HCLT 结果;3) run-live-smoke-readiness 通过;4) audit-docs、git diff --check 通过;5) 中文 commit 引用 HCLT。","受影响流程:HRWD 通讯录公网闭环 -> 事实源 -> 公网 readiness -> 提交;验证步骤:QA_ISSUE_ID=HCLT LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;node scripts/qa/audit-docs.mjs --check-registry;git diff --check;git commit -m 'HCLT 收口企微通讯录公网闭环测试'。","bash|node|git","DONE","DONE","DONE","docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|issues/2026-05-17_21-41-43-hrwd-contact-live-test.csv","[""HCLT-6""]","公网测试员工最终保留离职/禁用状态;不删除成员,不重置数据库。" diff --git a/scripts/qa/run-hrwd-contact-live-test.mjs b/scripts/qa/run-hrwd-contact-live-test.mjs new file mode 100644 index 00000000..7f3aeda6 --- /dev/null +++ b/scripts/qa/run-hrwd-contact-live-test.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomInt } from "node:crypto"; + +const DEFAULT_BASE_URL = "https://oa.hyinda.com"; +const DEFAULT_REPORT_ROOT = "artifacts/qa-flow"; +const REQUIRED_APPROVAL = "HRWD_CONTACT_LIVE_APPROVED"; + +function parseArgs(argv) { + const options = { + baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL, + issueId: process.env.QA_ISSUE_ID || "HCLT", + reportDir: process.env.HRWD_CONTACT_LIVE_REPORT_DIR || "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg.startsWith("--base-url=")) { + options.baseUrl = arg.slice("--base-url=".length); + } else if (arg.startsWith("--report-dir=")) { + options.reportDir = arg.slice("--report-dir=".length); + } + } + + options.baseUrl = options.baseUrl.trim().replace(/\/$/, ""); + options.issueId = options.issueId.trim() || "HCLT"; + if (!options.reportDir) { + const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); + options.reportDir = join(DEFAULT_REPORT_ROOT, `hrwd-contact-live-test-${stamp}`); + } + return options; +} + +function getCredential(role) { + const prefixByRole = { + admin: "LIVE_SMOKE_ADMIN", + employee: "LIVE_SMOKE_EMPLOYEE", + }; + const prefix = prefixByRole[role]; + const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim(); + const password = process.env[`${prefix}_PASSWORD`]?.trim(); + if (!identifier || !password) { + return null; + } + return { identifier, password }; +} + +async function fetchJson(url, init = {}) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { ok: response.ok, status: response.status, json }; +} + +function summarizeError(json) { + const error = json?.error ?? {}; + return { + code: typeof error.code === "string" ? error.code : null, + featureFlag: typeof error.details?.featureFlag === "string" ? error.details.featureFlag : null, + reason: typeof error.details?.reason === "string" ? error.details.reason : null, + }; +} + +async function login(baseUrl, role, credential) { + const result = await fetchJson(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": `hrwd-contact-live-${role}`, + }, + body: JSON.stringify({ + identifier: credential.identifier, + password: credential.password, + }), + }); + const accessToken = result.json?.data?.accessToken; + if (!result.ok || typeof accessToken !== "string" || !accessToken) { + throw new Error(`${role} login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim()); + } + return accessToken; +} + +async function callApi(baseUrl, token, method, pathName, body) { + return fetchJson(`${baseUrl}${pathName}`, { + method, + headers: { + authorization: `Bearer ${token}`, + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); +} + +function maskedId(value) { + const raw = String(value ?? ""); + if (!raw) return null; + return raw.length <= 8 ? `${raw.slice(0, 2)}***` : `${raw.slice(0, 8)}***`; +} + +function buildMarker() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, "0"); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(""); +} + +function randomTestPhone() { + return `199${String(randomInt(0, 100_000_000)).padStart(8, "0")}`; +} + +function buildTestPayload(marker, attempt) { + const suffix = attempt === 0 ? marker : `${marker}R${attempt}`; + return { + employeeNo: `HRWDTEST-${suffix}`, + name: `HRWDTEST-${suffix}`, + phone: randomTestPhone(), + email: `hrwdtest-${suffix.toLowerCase()}@hyinda.com`, + title: "HRWDTEST-Onboarding", + roles: ["employee"], + syncWecomDirectory: false, + }; +} + +function syncRunSummary(run) { + if (!run) { + return null; + } + const responseSummary = run.responseSummary && typeof run.responseSummary === "object" ? run.responseSummary : {}; + return { + operation: run.operation ?? null, + syncStatus: run.status ?? null, + code: run.errorCode ?? null, + errcode: responseSummary.errcode ?? null, + externalUserIdPrefix: maskedId(run.externalUserId), + }; +} + +function assertWecomSyncSuccess(label, syncResult, expectedOperation) { + const run = syncResult?.run ?? null; + const errcode = run?.responseSummary?.errcode; + if ( + !syncResult + || syncResult.status !== "success" + || syncResult.code !== "wecom_contact_synced" + || run?.operation !== expectedOperation + || Number(errcode ?? 0) !== 0 + ) { + const summary = syncRunSummary(run); + throw new Error(`${label} failed: ${JSON.stringify({ + status: syncResult?.status ?? null, + code: syncResult?.code ?? null, + run: summary, + })}`); + } + return syncRunSummary(run); +} + +function passStep(name, details = {}) { + return { name, ...details, status: "PASS" }; +} + +function failStep(name, details = {}) { + return { name, ...details, status: "FAIL" }; +} + +async function createEmployeeWithRetry(baseUrl, adminToken, marker) { + let lastError = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + const payload = buildTestPayload(marker, attempt); + const result = await callApi(baseUrl, adminToken, "POST", "/api/users", payload); + if (result.ok) { + const data = result.json?.data ?? {}; + return { + employee: data, + oneTimePassword: data.oneTimePassword, + marker: payload.name, + }; + } + lastError = { + httpStatus: result.status, + error: summarizeError(result.json), + }; + if (result.status !== 409) { + break; + } + } + throw new Error(`employee create failed: ${JSON.stringify(lastError)}`); +} + +function getPendingOffboardingTasks(profileLike) { + const tasks = profileLike?.lifecycle?.checklistTasks ?? profileLike?.checklistTasks ?? []; + return tasks.filter((task) => task.scene === "offboarding" && task.status === "pending"); +} + +async function getDirectoryState(baseUrl, adminToken, employeeId) { + const result = await callApi(baseUrl, adminToken, "GET", `/api/employees/${employeeId}/wecom-directory-sync`); + if (!result.ok) { + throw new Error(`directory state failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim()); + } + return result.json?.data ?? {}; +} + +async function assertAuditAction(baseUrl, adminToken, action, targetId, dateFrom) { + const params = new URLSearchParams({ + action, + targetType: "employee", + page: "1", + pageSize: "100", + dateFrom, + }); + const result = await callApi(baseUrl, adminToken, "GET", `/api/audit-logs?${params.toString()}`); + if (!result.ok) { + throw new Error(`audit ${action} failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim()); + } + const items = result.json?.data?.items ?? []; + const matched = items.some((item) => item.targetId === targetId && item.action === action); + if (!matched) { + throw new Error(`audit ${action} not found for target`); + } + return { action, matched: true }; +} + +async function assertEmployeeCannotAccessSync(baseUrl, employeeToken, targetEmployeeId) { + const view = await callApi(baseUrl, employeeToken, "GET", `/api/employees/${targetEmployeeId}/wecom-directory-sync`); + if (view.status !== 403) { + throw new Error(`employee sync view should be forbidden, got HTTP ${view.status}`); + } + const retry = await callApi(baseUrl, employeeToken, "POST", `/api/employees/${targetEmployeeId}/wecom-directory-sync/retry`, { + operation: "update", + }); + if (retry.status !== 403) { + throw new Error(`employee sync retry should be forbidden, got HTTP ${retry.status}`); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (process.env[REQUIRED_APPROVAL] !== "1") { + throw new Error(`${REQUIRED_APPROVAL}=1 is required before public HRWDTEST writes`); + } + + const adminCredential = getCredential("admin"); + const employeeCredential = getCredential("employee"); + if (!adminCredential || !employeeCredential) { + throw new Error("missing LIVE_SMOKE_ADMIN_* or LIVE_SMOKE_EMPLOYEE_* credentials"); + } + + mkdirSync(options.reportDir, { recursive: true }); + const startedAt = new Date().toISOString(); + const markerStamp = buildMarker(); + const steps = []; + let createdEmployeeId = null; + let createdMarker = `HRWDTEST-${markerStamp}`; + let finalStatus = "FAIL"; + + try { + const adminToken = await login(options.baseUrl, "admin", adminCredential); + steps.push(passStep("admin-login")); + const employeeToken = await login(options.baseUrl, "employee", employeeCredential); + steps.push(passStep("employee-login")); + + const created = await createEmployeeWithRetry(options.baseUrl, adminToken, markerStamp); + const employee = created.employee; + createdEmployeeId = employee.id; + createdMarker = created.marker; + const oneTimePassword = created.oneTimePassword; + if (!createdEmployeeId || !oneTimePassword) { + throw new Error("employee create response missing id or one-time password"); + } + steps.push(passStep("employee-created", { + employeeIdPrefix: maskedId(createdEmployeeId), + marker: createdMarker, + wecomSyncSkippedAtCreate: employee.wecomDirectorySync == null, + })); + + const onboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/onboarding/start`, { + effectiveDate: new Date().toISOString().slice(0, 10), + notes: `${options.issueId} ${createdMarker}`, + }); + if (!onboarding.ok) { + throw new Error(`onboarding failed: HTTP ${onboarding.status} ${summarizeError(onboarding.json).code ?? ""}`.trim()); + } + const createSync = assertWecomSyncSuccess("wecom create", onboarding.json?.data?.wecomDirectorySync, "create"); + steps.push(passStep("wecom-create", createSync)); + + await assertEmployeeCannotAccessSync(options.baseUrl, employeeToken, createdEmployeeId); + steps.push(passStep("employee-sync-permission-denied")); + + const updatedTitle = `HRWDTEST-Update-${markerStamp}`; + const update = await callApi(options.baseUrl, adminToken, "PATCH", `/api/employees/${createdEmployeeId}/profile`, { + title: updatedTitle, + }); + if (!update.ok) { + throw new Error(`profile update failed: HTTP ${update.status} ${summarizeError(update.json).code ?? ""}`.trim()); + } + const updateSync = assertWecomSyncSuccess("wecom update", update.json?.data?.wecomDirectorySync, "update"); + steps.push(passStep("wecom-update", updateSync)); + + const offboarding = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/offboarding/start`, { + effectiveDate: new Date().toISOString().slice(0, 10), + lastWorkingDate: new Date().toISOString().slice(0, 10), + notes: `${options.issueId} ${createdMarker}`, + }); + if (!offboarding.ok) { + throw new Error(`offboarding start failed: HTTP ${offboarding.status} ${summarizeError(offboarding.json).code ?? ""}`.trim()); + } + steps.push(passStep("offboarding-started")); + + let profile = offboarding.json?.data ?? {}; + let tasks = getPendingOffboardingTasks(profile); + if (!tasks.length) { + const latestProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`); + if (!latestProfile.ok) { + throw new Error(`profile fetch failed: HTTP ${latestProfile.status} ${summarizeError(latestProfile.json).code ?? ""}`.trim()); + } + profile = latestProfile.json?.data ?? {}; + tasks = getPendingOffboardingTasks(profile); + } + if (!tasks.length) { + throw new Error("offboarding checklist has no pending tasks"); + } + + let disableSync = null; + for (const task of tasks) { + const completed = await callApi(options.baseUrl, adminToken, "POST", `/api/employees/${createdEmployeeId}/checklist-tasks/${task.id}/complete`, {}); + if (!completed.ok) { + throw new Error(`checklist complete failed: HTTP ${completed.status} ${summarizeError(completed.json).code ?? ""}`.trim()); + } + if (completed.json?.data?.wecomDirectorySync) { + disableSync = completed.json.data.wecomDirectorySync; + } + } + const disableSummary = assertWecomSyncSuccess("wecom disable", disableSync, "disable"); + steps.push(passStep("wecom-disable", { + ...disableSummary, + completedTaskCount: tasks.length, + })); + + const finalProfile = await callApi(options.baseUrl, adminToken, "GET", `/api/employees/${createdEmployeeId}/profile`); + if (!finalProfile.ok) { + throw new Error(`final profile failed: HTTP ${finalProfile.status} ${summarizeError(finalProfile.json).code ?? ""}`.trim()); + } + const finalEmployee = finalProfile.json?.data?.employee ?? {}; + if (finalEmployee.status !== "archived" || finalEmployee.employmentStatus !== "inactive") { + throw new Error(`employee final state mismatch: ${JSON.stringify({ + status: finalEmployee.status ?? null, + employmentStatus: finalEmployee.employmentStatus ?? null, + })}`); + } + steps.push(passStep("oa-employee-disabled", { + employeeStatus: finalEmployee.status, + employmentStatus: finalEmployee.employmentStatus, + })); + + const deniedLogin = await fetchJson(`${options.baseUrl}/api/auth/login`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-device-fingerprint": "hrwd-contact-live-disabled-employee", + }, + body: JSON.stringify({ + identifier: employee.email, + password: oneTimePassword, + }), + }); + if (deniedLogin.ok || deniedLogin.json?.data?.accessToken) { + throw new Error("disabled employee login unexpectedly succeeded"); + } + steps.push(passStep("disabled-employee-login-denied", { + httpStatus: deniedLogin.status, + code: summarizeError(deniedLogin.json).code, + })); + + const directoryState = await getDirectoryState(options.baseUrl, adminToken, createdEmployeeId); + const latestSync = directoryState.wecomDirectory?.latestSync ?? null; + steps.push(passStep("admin-sync-state-readable", { + itemCount: Array.isArray(directoryState.items) ? directoryState.items.length : null, + latest: syncRunSummary(latestSync), + })); + + const auditResults = []; + for (const action of ["WECOM_DIRECTORY_CREATE", "WECOM_DIRECTORY_UPDATE", "WECOM_DIRECTORY_DISABLE"]) { + auditResults.push(await assertAuditAction(options.baseUrl, adminToken, action, createdEmployeeId, startedAt)); + } + steps.push(passStep("audit-actions", { + actions: auditResults.map((item) => item.action), + })); + + finalStatus = "PASS"; + } catch (error) { + steps.push(failStep("hrwd-contact-live-test", { + message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500), + })); + } finally { + const summary = { + checkedAt: new Date().toISOString(), + issueId: options.issueId, + baseUrl: options.baseUrl, + status: finalStatus, + marker: createdMarker, + employeeIdPrefix: maskedId(createdEmployeeId), + finalPolicy: "keep_offboarded_and_wecom_disabled_do_not_delete", + steps, + }; + + writeFileSync(join(options.reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + console.log(`[hrwd-contact-live-test] status=${summary.status} base=${summary.baseUrl} marker=${summary.marker}`); + for (const step of steps) { + const detail = step.httpStatus ? ` http=${step.httpStatus}` : ""; + const code = step.code ? ` code=${step.code}` : ""; + console.log(`[hrwd-contact-live-test] ${step.name} status=${step.status}${detail}${code}`); + } + console.log(`[hrwd-contact-live-test] report=${options.reportDir}`); + } + + if (finalStatus !== "PASS") { + process.exit(1); + } +} + +main().catch((error) => { + console.error(`[hrwd-contact-live-test][ERROR] ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +});