WARCH3/WCHECK1/WEXT1 完成受控上线后续自动化
Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
@@ -18,6 +18,7 @@ Read only what is relevant:
|
||||
- For HR lifecycle, employee onboarding/offboarding, Enterprise WeChat directory sync, account disablement, or Enterprise WeChat API work, read `docs/需求-企业微信基础设施规约.md`, `docs/企微API能力目录.md`, `docs/管理员手册.md`, and the latest HCLT/HRWD/WAPI/WRRA Issue CSV evidence first.
|
||||
- For any Enterprise WeChat capability, Secret, callback, approval, check-in, HR assistant, or contact-directory task, read `docs/企微API能力目录.md` before planning or coding. WAPI fixes the current Secret matrix: contacts use `WECOM_CONTACT_SECRET`; workbench/OAuth/messages/HR assistant/roster/check-in/approval use `WECOM_AGENT_SECRET`; `WECOM_CHECKIN_SECRET` is only an explicit check-in override; `WECOM_HR_ASSISTANT_SECRET` is historical compatibility and not a default. Do not write runtime code that tries multiple Secrets to guess the correct one.
|
||||
- For Enterprise WeChat-first HR/attendance runtime migration, read `docs/企微优先开发路线图.md` before planning or coding. WDRG makes the roadmap authoritative for stage order; WAPI adds the runtime-first stage before deep mirror work; WRRA states that WOUA `PASS_WITH_FINDINGS` does not clear deep mirror work; WOUA-FIX has now soft-archived the 7 OA active test accounts missing in WeCom, fixed the `T0000` seed reactivation behavior, transferred the `SMOKE201` test-manager side effects, and rerun WOUA with `oaActiveMissingInWecom=0`. WCPC has rerun the capability POC with an existing active test account and reached `GO_TO_HR_MASTER_SYNC`; WHMS/WHMSLIVE has implemented, deployed, and publicly verified `wecom_hr_master_sync_v1`, with the production flag restored to `force_off` and production real-employee batch apply not approved. WMSG1 proves real app-message delivery and Deeplink return only; WAPC1 fixes the approval configuration preparation checklist before mirror work; WAPC2 adds the leave/missed-punch approval template and low-risk `sp_no` read-only POC runner. WPROC1/WPROC2 fixed the engineering process gate; WEGOV1 is the route gate, and WACP3R has now rerun the compensation-pull POC by automatically discovering OA-POC leave/missed-punch template IDs from `SMOKE002` creator. Public report `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` is `PASS_WITH_FINDINGS`: compensation pull and detail read work, with finding that callback evidence files are not persisted across deploys. WREAD1/WREAD1F have now completed real-device readability confirmation for leave and missed-punch: public report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`; the user confirmed cards/lists/details are readable, reject has second confirmation, and approve is single-tap. WADM1 has completed the self-owned archive/mirror data-model design. WARCH1 has implemented and publicly verified the minimum leave/missed-punch archive slice: Prisma archive/callback/compensation/finding tables, callback DB persistence with non-200 retry on DB failure, compensation persistence behind `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`, and offline `npm run check:wecom-approval-archive`; public reports `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` and `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` are `PASS_WITH_FINDINGS`, with 2 archived histories readable and only a historical pre-WARCH1 callback gap finding. WARCH2 has now added the formal OA read-only archive API/page: `/api/wecom/approval-archive`, `/wecom-approval-archive`, and `/mobile/wecom-approval-archive` read OA DB only, do not call WeCom API or read WeCom Secrets, and enforce self/direct-report/HR-admin visibility with unmapped applicant records HR/admin-only. WROAD2+OPS is the current controlled launch gate: use test accounts `SMOKE002 -> SMOKE101 + wangshunli`, run `scripts/qa/run-wroad2-ops-daily.sh` manually once per day for 3 days, allow only compensation archive writes to OA DB via `WECOM_ARCHIVE_WRITE=1`, and do not create WeCom templates/approvals, write WeCom, install timers, or expand real users. Day 0 public report `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` is `PASS_WITH_FINDINGS`: WeCom runtime, compensation archive persistence, offline archive read, and privacy smoke pass; findings are missing live-smoke login credentials/pending OCR subcheck and historical callback gap. It still does not implement full mirror, outing/overtime, emergency mode, writeback, or RuleOps. WPROC2 adds the executable `process gate`: before WAPC2F or other high-risk WeCom work, run `node scripts/qa/check-process-contract.mjs --baseline` and the issue-specific profile check to enforce DoR/DoD, fixed Secret source, non-self approval, live write switches, missing-config classification, and redaction boundaries. Do not start `wecom_attendance_approval_mirror_v1`, `oa_hr_emergency_mode_v1`, `wecom_recovery_writeback_v1`, or `wecom_ruleops_v1` without WROAD2+OPS, WARCH3, WCHECK1, WEXT1 decision, and the next stage's own Issue CSV and acceptance gates.
|
||||
- WARCH3/WCHECK1/WEXT1 update the current route after WROAD2 Day 0: WARCH3 adds `scripts/qa/run-warch3-ops-daily.sh`, `deploy/trialrun/install-warch3-ops-timer.sh`, and `npm run send:wecom-ops-alert` for 07:30 systemd巡检和脱敏企微告警;public report `/app/artifacts/qa-flow/warch3-ops-20260520-054841/summary.json` is `PASS_WITH_FINDINGS`, and alert report `/app/artifacts/qa-flow/wecom-ops-alert-20260520T054851Z/summary.json` is `PASS_ALERT_SENT_WITH_CONFIG_GAPS` with `HYD000006` notified and `wangshunli` kept as an alias gap. WCHECK1 adds `npm run check:wecom-checkin-summary-archive`, daily/monthly check-in summaries, and rule snapshots with `--persist-to-db` + `WECOM_CHECKIN_ARCHIVE_WRITE=1` as the only OA DB write gate; public reports `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054907Z/summary.json` and `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054926Z/summary.json` are `PASS_CHECKIN_ARCHIVE_READY` and `PASS_CHECKIN_ARCHIVE_OFFLINE_READY`. WEXT1 only extends approval fixtures for `--target=outing --target=overtime` dry-run; public report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260520T054940Z/summary.json` is `PASS_FIXTURE_DRY_RUN_READY`. These stages still do not create WeCom templates/approvals, expand real users, or implement full mirror/emergency/writeback/RuleOps. Next real-user step is WREAL1 after WROAD2 three-day closeout and explainable WARCH3/WCHECK1/WEXT1 evidence.
|
||||
- WAPC2FLIVE supersedes the WAPC2F dry-run gap for OA-POC samples: after user approval, the runner was corrected to the official `template_name` + `property` controls create-template payload, deployed with `SYNC_ENV=0`, and the public live report `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T053400Z/summary.json` reached `PASS_FIXTURE_CREATED`; leave/missed-punch OA-POC templates and low-risk test approvals were created and retained for audit, with WAPC2 auto-rerun `PASS_APPROVAL_POC`. WCBK1 has added and deployed read-only `GET/POST /api/wecom/approval/callback`, `wecomCallbackCrypto`, `npm run check:wecom-approval-callback-poc`, and the `wecom-callback` process gate. After remote callback config was set, public report `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T083422Z/summary.json` reached `PASS_CALLBACK_URL_VERIFIED`; after `SMOKE101` approved an existing OA-POC missed-punch test approval, `/app/artifacts/qa-flow/wecom-approval-callback-poc-20260519T092648Z/summary.json` reached `PASS_CALLBACK_EVENT_CONFIRMED` with one redacted event. WACP3/WACP3R has added and deployed `npm run check:wecom-approval-compensation-poc`, `POST /cgi-bin/oa/getapprovalinfo` read support, and automatic template discovery by `SMOKE002` creator; WREAD1 deploy reran WACP3R at `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T125515Z/summary.json` and then generated `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T125532Z/summary.json` as `PAUSED_MANUAL_CONFIRMATION`. WREAD1F then fixed duplicate OA-POC template names by adding timestamp uniqueness and created fresh leave/missed-punch approvals at `/app/artifacts/qa-flow/wecom-approval-fixtures-20260519T131958Z/summary.json`; final WREAD1 manual report `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` is `PASS_READABLE`. This clears leave/missed-punch readability only; mirror data model, minimum archive, emergency mode, writeback, RuleOps, and outing/overtime sample decisions remain later gates.
|
||||
- WEGOV1 changes the current WeCom route to "Enterprise WeChat owns mature HR/attendance flows; OA stores self-owned history, audit, entry, messages, and emergency fallback." Treat four domains explicitly: WeCom-owned domain is check-in, leave, missed punch, outing, overtime, leave balance, and HR assistant; OA-owned domain remains reimbursement, procurement, contracts, projects, payments, invoices, bank receipts, sensitive attachments, and operating audit; OA archive domain stores WeCom business history, full external keys, and necessary snapshots; OA emergency domain is manually enabled only when WeCom is unavailable. Do not assume a full isolated WeCom sandbox; use the controlled test domain `SMOKE002 -> SMOKE101`, `OA-POC` templates, low-risk approvals, synthetic tests, public live runners, and limited real-device confirmation. WARCH1 now supplies the minimum leave/missed-punch archive implementation, WARCH2 supplies the formal read-only history query surface, and WROAD2 supplies controlled ops daily checks. Do not start deep mirror until WROAD2+OPS, WARCH3, WCHECK1, WEXT1 decision, and the mirror stage's own contract are complete.
|
||||
- For reimbursement, invoice, OCR, duplicate invoice, or attachment work, read `docs/报销发票附件规则.md` and `references/reimbursement.md`.
|
||||
@@ -108,7 +109,8 @@ Final responses should include:
|
||||
- R6 scope defaults to reimbursement, leave, procurement, approval, Enterprise WeChat entry/notification, finance payment/export.
|
||||
- Do not expand to budget, ERP, AI, full travel, or complex BPM during R6; record those as follow-up Issues.
|
||||
- Enterprise WeChat personal app message is the formal approval notification channel after R5; mock and group bots are not acceptance evidence for R6 business notification.
|
||||
- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Before runtime migration, read `docs/企微API能力目录.md` and `docs/企微优先开发路线图.md`, run or check `WECOM-CAPABILITY-POC-V1`/WCPC, and classify each capability as `CAN_REUSE_NOW`, `NEEDS_CONFIG`, `NEEDS_API_POC`, `KEEP_OA_PRIMARY`, or `NOT_SUPPORTED_BY_API`; missing Secret, callback, template, trusted IP, visible scope, or API permission must be recorded as `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`, not PASS. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET` and `POST /cgi-bin/user/list_id`; HR assistant/roster uses `WECOM_AGENT_SECRET` and `POST /cgi-bin/hr/get_staff_info`; check-in defaults to `WECOM_AGENT_SECRET` with optional explicit `WECOM_CHECKIN_SECRET`; approval template/detail reads use `WECOM_AGENT_SECRET`; no runtime Secret guessing. WARCH1 now implements the minimum leave/missed-punch archive slice: callback events and compensation details can persist to OA DB, report/log output stays redacted, and `npm run check:wecom-approval-archive -- --offline --format=json` proves saved history can be read without WeCom API. WARCH2 now exposes that saved history through OA read-only API and PC/mobile pages with redacted output and HR/admin-only access for unmapped applicant records. WROAD2+OPS is the current controlled launch and daily ops gate; its script may write only OA archive persistence and must not write WeCom. Attendance/approval deep mirror, emergency mode, recovery writeback, RuleOps, outing/overtime samples, and HR write APIs remain later-stage gaps. Do not skip stage order: `WECOM-CAPABILITY-POC-V1`/WCPC -> `wecom_hr_master_sync_v1`/WHMSLIVE -> `runtime_first_wecom_enablement`/WAPI+WOUA rerun -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班延期决策` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. OA-driven Enterprise WeChat directory create/update/disable is live verified; for later HR/WeCom directory issues, first check configuration, permission, department, and data-specific causes before treating the capability as unfinished.
|
||||
- After WAPI, ERP is not a future OA integration target. HR/attendance employee daily approvals (leave, missed punch, outing, overtime) should reuse mature Enterprise WeChat capabilities first, while OA provides self-owned archive, domain-based mirror, manual takeover, recovery writeback, finance closeout, and audit. Reimbursement, payment batches, AP, accounting handoff, procurement, expenses, contracts, projects, sensitive attachments, invoices, bank receipts, and accounting handoff materials remain OA-primary unless a later Issue changes that domain explicitly. OA-primary approvals may send Enterprise WeChat personal-app notifications, but messages must contain only dehydrated title/type/no/status/deeplink; do not upload procurement forms, contract bodies, quotes, supplier files, invoices, bank receipts, or accounting materials to WeCom native approvals or WeCom attachments. Current WAPI rule: contacts read with `WECOM_CONTACT_SECRET`; HR assistant/roster/check-in/approval default to `WECOM_AGENT_SECRET`, with `WECOM_CHECKIN_SECRET` only as explicit check-in override; no runtime Secret guessing. Current stage order is `WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WREAL1 小范围真实试用 -> wecom_attendance_approval_mirror_v1 -> oa_hr_emergency_mode_v1 -> wecom_recovery_writeback_v1 -> wecom_ruleops_v1`. UI and API work must distinguish `OA 主办`, `企微主办`, `OA 自建留存`, and `OA 应急接管`. Do not treat WEXT1 dry-run as permission to live-create outing/overtime templates or approvals; that needs a new Issue CSV and explicit user approval.
|
||||
- Current post-WROAD2 route wording is: `WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WREAL1 小范围真实试用 -> wecom_attendance_approval_mirror_v1`. Do not treat WEXT1 dry-run as permission to live-create outing/overtime templates or approvals; that needs a new Issue CSV and explicit user approval.
|
||||
- WMSG1 has verified real Enterprise WeChat personal-app delivery and Deeplink return: report `/app/artifacts/qa-flow/wecom-runtime-ready-20260518T234431Z/summary.json` has `checks.message.status=OK`, `errcode=0`, and `recipientCount=1`; the user confirmed receiving "OA 运行就绪检查" and opening the OA notifications page from the message. This only clears message/deeplink runtime evidence; template/detail, callback evidence, compensation pull, readability, self-owned archive model design, and minimum archive implementation are covered by WAPC2FLIVE/WCBK1/WACP3R/WREAD1/WADM1/WARCH1 in the OA-POC leave/missed-punch scope, while deep mirror work remains a separate later gate.
|
||||
- WAPC1 has fixed the WeCom approval configuration preparation gate: first templates are leave, missed punch, outing, and overtime; each needs template ID, scope, fields, approver rules, visible scope, and self-built app permission; low-risk test `sp_no` must not contain procurement, contract, reimbursement, invoice, bank receipt, sensitive attachment, or real sensitive business material; callback URL/Token/EncodingAESKey are deployment-only values and must not be written to repo docs or Issue CSV. Do not implement real approval callback handling, deep mirror, emergency mode, recovery writeback, or RuleOps until a later approved Issue covers the POC or implementation.
|
||||
- WAPC2 has added the WeCom approval template/detail read-only POC runner for leave and missed punch. Use `npm run check:wecom-approval-poc`; pass template IDs with `--template=leave:<id>` / `--template=missed_punch:<id>` and low-risk test approval numbers with `--sp-no=leave:<sp_no>` / `--sp-no=missed_punch:<sp_no>`. The runner must use only `WECOM_AGENT_SECRET`, redact full template IDs and `sp_no`, and never output form values, attachments, comments, full userid, token, Secret, or raw payload.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE_DIR="${REMOTE_DIR:-/opt/hyb-oa-trialrun}"
|
||||
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-${REMOTE_DIR}/deploy/trialrun/.env}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-deploy/trialrun/docker-compose.yml}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-hyb-oa-warch3-ops}"
|
||||
RUN_AT="${RUN_AT:-07:30}"
|
||||
MODE="${1:-install}"
|
||||
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
TIMER_FILE="/etc/systemd/system/${SERVICE_NAME}.timer"
|
||||
|
||||
need_root() {
|
||||
if [[ "$(id -u)" != "0" ]]; then
|
||||
echo "[ERROR] 请以 root 执行,或使用 sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_timer() {
|
||||
need_root
|
||||
systemctl disable --now "${SERVICE_NAME}.timer" >/dev/null 2>&1 || true
|
||||
systemctl stop "${SERVICE_NAME}.service" >/dev/null 2>&1 || true
|
||||
rm -f "$SERVICE_FILE" "$TIMER_FILE"
|
||||
systemctl daemon-reload
|
||||
echo "[warch3-ops-timer] uninstalled ${SERVICE_NAME}"
|
||||
}
|
||||
|
||||
install_timer() {
|
||||
need_root
|
||||
if [[ ! -d "$REMOTE_DIR" ]]; then
|
||||
echo "[ERROR] REMOTE_DIR 不存在: $REMOTE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$REMOTE_ENV_FILE" ]]; then
|
||||
echo "[ERROR] 远端 env 不存在: $REMOTE_ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local compose_exec
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
compose_exec="docker compose"
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
compose_exec="docker-compose"
|
||||
else
|
||||
echo "[ERROR] 远端既没有 docker compose,也没有 docker-compose" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat >"$SERVICE_FILE" <<SERVICE
|
||||
[Unit]
|
||||
Description=hyb-oa WARCH3 archive stabilization ops daily check
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
WorkingDirectory=${REMOTE_DIR}
|
||||
Environment=REMOTE_DIR=${REMOTE_DIR}
|
||||
Environment=REMOTE_ENV_FILE=${REMOTE_ENV_FILE}
|
||||
Environment=COMPOSE_FILE=${COMPOSE_FILE}
|
||||
Environment=LIVE_SMOKE_BASE_URL=https://oa.hyinda.com
|
||||
Environment=WARCH3_SEND_ALERT=1
|
||||
Environment=WARCH3_OPS_ALERT_SEND=1
|
||||
ExecStart=/bin/bash -lc 'cd "$REMOTE_DIR" && ${compose_exec} -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" exec -T server sh -lc "cd /app && LIVE_SMOKE_BASE_URL=https://oa.hyinda.com WARCH3_SEND_ALERT=1 WARCH3_OPS_ALERT_SEND=1 bash scripts/qa/run-warch3-ops-daily.sh"'
|
||||
SERVICE
|
||||
|
||||
cat >"$TIMER_FILE" <<TIMER
|
||||
[Unit]
|
||||
Description=Run hyb-oa WARCH3 archive stabilization ops daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* ${RUN_AT}:00
|
||||
Persistent=true
|
||||
Unit=${SERVICE_NAME}.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
TIMER
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "${SERVICE_NAME}.timer"
|
||||
systemctl list-timers "${SERVICE_NAME}.timer" --no-pager
|
||||
echo "[warch3-ops-timer] installed ${SERVICE_NAME}.timer at ${RUN_AT} local server time (Asia/Shanghai expected)"
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
install|"")
|
||||
install_timer
|
||||
;;
|
||||
--uninstall|uninstall)
|
||||
uninstall_timer
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: REMOTE_DIR=/opt/hyb-oa-trialrun bash deploy/trialrun/install-warch3-ops-timer.sh [install|uninstall]"
|
||||
;;
|
||||
*)
|
||||
echo "[ERROR] unsupported mode: $MODE" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+14
-1
@@ -128,7 +128,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| WADM1 | 已完成 docs-only 数据模型设计:固定企微审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;不改 schema、不部署、不写业务数据 | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` |
|
||||
| WARCH1 | 已实现并部署验证最小审批历史留存:新增 Prisma 留存表和 migration、回调 DB 入库、补偿拉取双开关入库、离线 OA DB 读取 runner;公网已写入 OA 留存 2 条请假/补卡历史并离线可读,历史回调 gap 已审计化 | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json`、`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` |
|
||||
| WARCH2 | 已实现并部署验证正式只读历史查询:新增企微审批历史 API、PC/移动历史页、分层权限和脱敏白名单;只读 OA DB,不调用企微 API;远端离线归档复跑可读 2 条请假/补卡历史,保留历史 callback gap finding | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv`、`/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json`、`server/src/routes/wecomApprovalArchive.js`、`client/src/pages/WecomApprovalArchivePage.tsx` |
|
||||
| WROAD2+OPS | 当前受控上线与每日巡检合同:试用范围固定测试账号,3 天每日脚本化触发;巡检可写 OA 自建留存库但不写企业微信,不安装定时任务;Day 0 公网报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,企微 runtime、补偿归档、离线归档读取和隐私扫描通过,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断受控试用 | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv`、`scripts/qa/run-wroad2-ops-daily.sh` |
|
||||
| WROAD2+OPS | 当前受控上线与每日巡检合同:试用范围固定测试账号,3 天每日脚本化触发;巡检可写 OA 自建留存库但不写企业微信;Day 0 公网报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,企微 runtime、补偿归档、离线归档读取和隐私扫描通过,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断受控试用;WARCH3 已承接后续 07:30 systemd 自动巡检 | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv`、`scripts/qa/run-wroad2-ops-daily.sh` |
|
||||
|
||||
## 当前门禁
|
||||
|
||||
@@ -454,6 +454,19 @@ Go 结论边界:
|
||||
|
||||
结论:当前可以关闭 `WF8-11`、`R7E-9`、`R7E-15`、`R7E-17`、`R7E-18`、`R7E-19`、`R7E-20`、`QAOPS8-17`、`QAOPS8-19`、`QAOPS8-20` 和 BIZ8 阶段流。`R7E-14` 已按付款批次延期到 P2 的变更口径收口;P2-FIN 决策准备已完成,后续优先级为 `P2-FIN-V1` 独立实现合同、图片 OCR 样票治理、UX8 真机反馈、审批通知和轻业务增强。P2 功能开发必须另建或延续对应连续合同,本报告不启动新功能。
|
||||
|
||||
## WARCH3 / WCHECK1 / WEXT1 受控上线后续
|
||||
|
||||
执行时间:2026-05-20。WROAD2 Day 0 公网巡检 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS` 后,后续路线按“受控上线稳定化 -> 打卡摘要留存 -> 外出/加班 dry-run -> 小范围真实试用”推进。
|
||||
|
||||
| 项 | 当前结果 | 证据和边界 |
|
||||
| --- | --- | --- |
|
||||
| WARCH3 自动巡检 | 公网已验证,`PASS_WITH_FINDINGS` | 新增 `scripts/qa/run-warch3-ops-daily.sh`、`deploy/trialrun/install-warch3-ops-timer.sh` 和 `npm run send:wecom-ops-alert`;服务器已安装 `hyb-oa-warch3-ops.timer`,下次触发为 2026-05-21 07:30 CST;手动 service 报告 `/app/artifacts/qa-flow/warch3-ops-20260520-054841/summary.json` 为 `PASS_WITH_FINDINGS`,告警报告 `/app/artifacts/qa-flow/wecom-ops-alert-20260520T054851Z/summary.json` 为 `PASS_ALERT_SENT_WITH_CONFIG_GAPS`,已向有效管理员 `HYD000006` 发送脱敏消息,`wangshunli` 仅作为非员工号别名 gap 记录 |
|
||||
| WCHECK1 打卡摘要留存 | 公网已验证,live/offline 均通过 | 新增打卡日/月摘要、规则快照、`wecomCheckinArchive` 服务和 `npm run check:wecom-checkin-summary-archive`;live 报告 `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054907Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_READY`,本窗口 0 条打卡明细、2 条规则快照已写 OA 留存;offline 报告 `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054926Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_OFFLINE_READY` |
|
||||
| WEXT1 外出/加班 | 公网 dry-run 通过,live 未批准 | `npm run check:wecom-approval-fixtures` 支持 `--target=outing --target=overtime`;公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260520T054940Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`;本轮只构建最小通用模板和审批申请 payload,不创建企微模板、不提交审批单 |
|
||||
| 隐私边界 | 保持 | 报告和告警只输出状态、计数、报告路径、脱敏 userid/template/sp_no 前缀;不输出手机号、邮箱、token、Secret、完整外部键、位置、人脸、设备明文、表单值、附件、评论或原始 payload |
|
||||
|
||||
上述能力仍不代表完整审批镜像、应急接管、恢复回写或 RuleOps 已完成。WREAL1 小范围真实试用需等 WROAD2 三天验收、WARCH3/WCHECK1/WEXT1 公网结果可解释后再单独问答确认。
|
||||
|
||||
## 历史恢复
|
||||
|
||||
需要追溯 R70-R91 的详细流水时,使用:
|
||||
|
||||
+11
-8
@@ -54,17 +54,17 @@
|
||||
| 花名册读取 | [获取员工花名册信息](https://developer.work.weixin.qq.com/document/path/99132) | `/cgi-bin/hr/get_staff_info` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW` | WHMS/WOUA 读取员工在职资料;不使用 `WECOM_HR_ASSISTANT_SECRET`。 |
|
||||
| 花名册更新 | [更新员工花名册信息](https://developer.work.weixin.qq.com/document/path/99133) | `/cgi-bin/hr/update_staff_info` | `POST` | `WECOM_AGENT_SECRET` | `NEEDS_API_POC_WRITE` | 写接口,后续需单独低风险 POC 和人工批准;本轮不调用。 |
|
||||
| 打卡概述 | [打卡](https://developer.work.weixin.qq.com/document/path/90261) | 记录、规则、日报、月报、排班 | 混合 | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_READ_PARTIAL` | 企业微信继续做原始采集和风控,OA 只读同步摘要和解释。 |
|
||||
| 打卡记录 | [获取打卡记录数据](https://developer.work.weixin.qq.com/document/path/90262) | `/cgi-bin/checkin/getcheckindata` | `POST` | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_READ` | 已用于考勤同步 V1;不保存完整位置、人脸、设备明文或原始 payload。 |
|
||||
| 员工打卡规则 | [获取员工打卡规则](https://developer.work.weixin.qq.com/document/path/90263) | `/cgi-bin/checkin/getcheckinoption` | `POST` | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_READ` | 可作为后续考勤解释和规则快照依据。 |
|
||||
| 打卡记录 | [获取打卡记录数据](https://developer.work.weixin.qq.com/document/path/90262) | `/cgi-bin/checkin/getcheckindata` | `POST` | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WCHECK1 复用该接口生成规范化明细、日/月摘要和异常摘要;写 OA 留存必须 `--persist-to-db` + `WECOM_CHECKIN_ARCHIVE_WRITE=1`,不写企业微信,不保存完整位置、人脸、设备明文或原始 payload。 |
|
||||
| 员工打卡规则 | [获取员工打卡规则](https://developer.work.weixin.qq.com/document/path/90263) | `/cgi-bin/checkin/getcheckinoption` | `POST` | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WCHECK1 已新增只读规则快照能力,只保存规则摘要和 hash;报告只输出脱敏 userid 前缀、计数和状态,不保存原始 payload。 |
|
||||
| 打卡日报/月报/排班 | [日报](https://developer.work.weixin.qq.com/document/path/93374)、[月报](https://developer.work.weixin.qq.com/document/path/93387)、[排班](https://developer.work.weixin.qq.com/document/path/93380) | 官方打卡扩展接口 | `POST` 为主 | `WECOM_AGENT_SECRET` 或显式 `WECOM_CHECKIN_SECRET` | `NEEDS_API_POC` | 不进入当前运行优先主线;后续考勤镜像合同时再做只读 POC。 |
|
||||
| 审批概述 | [审批](https://developer.work.weixin.qq.com/document/path/91854) | 模板、实例、回调、假期 | 混合 | `WECOM_AGENT_SECRET` | `PARTIAL_NEEDS_CONFIG` | HR/考勤员工日常审批可优先在企微处理,但 OA 镜像尚未实现。 |
|
||||
| 审批模板详情 | [获取审批模板详情](https://developer.work.weixin.qq.com/document/path/91982) | `/cgi-bin/oa/gettemplatedetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_POC_FIXTURES` | WAPC2 runner 已可读模板详情;WAPC2FLIVE 已自动创建 OA-POC 请假/补卡测试模板并复跑得到 `PASS_APPROVAL_POC`。正式模板和外出/加班仍需单独配置或 POC。 |
|
||||
| 审批申请详情 | [获取审批申请详情](https://developer.work.weixin.qq.com/document/path/91983) | `/cgi-bin/oa/getapprovaldetail` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WAPC2 runner 已可读审批详情;WAPC2FLIVE 已提交低风险请假/补卡测试审批单并自动复跑通过。WARCH1 复用该接口作为补偿拉取详情最终校准来源,可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 自建留存表;WROAD2 每日巡检只通过该路径补齐 OA 留存,不写企业微信。测试单不得包含采购、合同、报销、发票、银行回单或高敏附件。 |
|
||||
| 审批状态回调 | [审批申请状态变化回调通知](https://developer.work.weixin.qq.com/document/path/91815) | 事件回调 | `GET` URL 验证 / `POST` 事件 | 自建应用回调 Token/EncodingAESKey | `CAN_REUSE_NOW_FOR_ARCHIVE` | WCBK1 已部署只读 `GET/POST /api/wecom/approval/callback` 和 `check:wecom-approval-callback-poc`;公网真实事件已确认。WARCH1 后 POST 回调默认把规范化事件写入 `WecomApprovalCallbackEvent` 和 `WecomApprovalArchive`,DB 写入失败返回非 200 触发企微重试;不保存 Token、EncodingAESKey、原始 XML、密文或原始 payload。 |
|
||||
| 批量获取审批单号 | [批量获取审批单号](https://developer.work.weixin.qq.com/document/path/91816) | `/cgi-bin/oa/getapprovalinfo` | `POST` | `WECOM_AGENT_SECRET` | `CAN_REUSE_NOW_FOR_ARCHIVE` | WACP3/WACP3R 已新增 `check:wecom-approval-compensation-poc`,支持按 `creator` 自动发现 OA-POC 模板,再按 `template_id` filters 拉审批单号,并用 `getapprovaldetail` 拉详情;WARCH1 增加 OA DB 持久化双开关、补偿 run/item 和对账 finding,用于解决回调证据文件跨部署丢失问题。WROAD2+OPS 每日巡检复用该 runner;只允许以 `WECOM_ARCHIVE_WRITE=1` + `--persist-to-db` 写 OA 留存库,不写企微、不无过滤拉取高风险业务单据。 |
|
||||
| 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | `/cgi-bin/oa/applyevent` | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | 已接入 WAPC2F QA-only runner;仅可用于 OA-POC 低风险请假/补卡测试审批单自动样件。默认 dry-run 不获取写入 token、不提交审批;live 写入需显式 CLI、`WECOM_APPROVAL_FIXTURE_WRITE=1` 和人工批准,申请人/审批人必须非自审。WAPC2FLIVE 已提交请假/补卡测试审批单并保留审计;OA 主办报销、采购、合同、项目不迁到企微原生审批。 |
|
||||
| 提交审批申请 | [提交审批申请](https://developer.work.weixin.qq.com/document/path/91853) | `/cgi-bin/oa/applyevent` | `POST` | `WECOM_AGENT_SECRET` | `DONE_LIVE_VERIFIED_FOR_OA_POC_FIXTURES` | 已接入 WAPC2F QA-only runner;请假/补卡已完成一次 live 创建并保留审计。WEXT1 扩展外出/加班 payload,但本轮只 dry-run;live 写入仍需显式 CLI、`WECOM_APPROVAL_FIXTURE_WRITE=1`、非自审预检和人工批准。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 写入仍留后期合同。 |
|
||||
| 审批模板创建/更新 | [创建审批模板](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 请假/补卡测试模板并保留审计。WEXT1 只为外出/加班构建 dry-run payload;正式模板更新、外出/加班 live 创建和 RuleOps 写入仍留后期合同。 |
|
||||
|
||||
## 4. WAPC1 审批配置准备清单
|
||||
|
||||
@@ -95,7 +95,7 @@ WAPC2 不代表真实回调、审批镜像、应急接管、恢复回写或 Rule
|
||||
|
||||
WPROC1 将 WAPC2 之后的审批路线固定为:
|
||||
|
||||
`WAPC2 -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班延期决策 -> wecom_attendance_approval_mirror_v1`
|
||||
`WAPC2 -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WREAL1 小范围真实试用 -> wecom_attendance_approval_mirror_v1`
|
||||
|
||||
门禁要求:
|
||||
|
||||
@@ -112,7 +112,10 @@ WPROC1 将 WAPC2 之后的审批路线固定为:
|
||||
- `WADM1` 必须先设计自建留存和镜像字段、状态机、幂等键、索引、隐私保留和迁移回滚方案,再进入 schema 或业务代码实现。
|
||||
- `WARCH1` 已把最小自建历史留存落到代码并完成公网验证:回调事件和补偿拉取可写 OA DB,离线 runner 可不调用企微 API 读取已保存历史。公网补偿持久化报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,离线读取报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`;finding 是 WARCH1 前旧回调未入 DB 的历史 gap。该阶段仍只覆盖请假/补卡,不等于完整镜像增强。
|
||||
- `WARCH2` 在 WARCH1 基础上新增 OA 正式只读历史查询:`GET /api/wecom/approval-archive` 和详情接口只读 OA DB,不调用企业微信 API、不读取企微 Secret、不新增企微写入。权限以 OA employee 关系为准:本人相关、直属下属、HR/admin/*;`leave.view_all` / `attendance.view_all` 不单独代表全量企微归档权限;申请人未映射或 callback-only 记录只允许 HR/admin/* 查看。
|
||||
- `WROAD2+OPS` 把 WARCH2 后能力先进入测试账号受控上线和 3 天每日巡检,使用 `scripts/qa/run-wroad2-ops-daily.sh` 编排现有 live runner。巡检默认只读,唯一允许写入是补偿归档写 OA DB;不创建企微模板、不提交审批单、不写企业微信、不安装 cron/systemd timer。Day 0 公网报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`:企微 runtime、补偿归档、离线归档读取和隐私扫描通过;finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。
|
||||
- `WROAD2+OPS` 把 WARCH2 后能力先进入测试账号受控上线和 3 天每日巡检,使用 `scripts/qa/run-wroad2-ops-daily.sh` 编排现有 live runner。Day 0 公网报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`:企微 runtime、补偿归档、离线归档读取和隐私扫描通过;finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。
|
||||
- `WARCH3` 新增 `scripts/qa/run-warch3-ops-daily.sh`、`deploy/trialrun/install-warch3-ops-timer.sh` 和 `npm run send:wecom-ops-alert`,把巡检升级为服务器 07:30 systemd timer,并在 finding/失败时给 `wangshunli/HYD000006` 发送脱敏企微告警;告警只含阶段、状态、finding 数、报告路径和下一步建议。
|
||||
- `WCHECK1` 新增 `npm run check:wecom-checkin-summary-archive`,用 `getcheckindata` 和 `getcheckinoption` 生成打卡摘要与规则快照;默认离线只读,写 OA 留存需 `WECOM_CHECKIN_ARCHIVE_WRITE=1`。
|
||||
- `WEXT1` 扩展 `npm run check:wecom-approval-fixtures` 支持 `--target=outing --target=overtime`,本轮只 dry-run,不 live 创建外出/加班模板或审批单。
|
||||
|
||||
### 6.1 WADM1 数据留存与 API 结果边界
|
||||
|
||||
@@ -127,8 +130,8 @@ WADM1/WARCH1 将已验证的审批能力固定为“可读可留存”的实现
|
||||
|
||||
## 7. 运行优先结论
|
||||
|
||||
- 近期优先利用:企业微信工作台入口、OAuth/免登、绑定、个人应用消息、OA deeplink、通讯录 userid 清单、可选成员详情、人事助手花名册读取、打卡记录和打卡规则读取,以及 WROAD2 测试账号受控上线巡检。
|
||||
- 暂缓深度镜像:WREAD1、WADM1、WARCH1、WARCH2 已完成请假/补卡可读性、模型设计、最小历史留存和正式只读查询;`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 仍需等 WROAD2+OPS、WARCH3、WCHECK1、外出/加班模板测试或延期决策完成后再拆;WOUA-FIX、WAPC2FLIVE、WCBK1、WACP3R 和 WARCH1 只作为已完成前置证据,不直接放行深度镜像。
|
||||
- 近期优先利用:企业微信工作台入口、OAuth/免登、绑定、个人应用消息、OA deeplink、通讯录 userid 清单、可选成员详情、人事助手花名册读取、打卡记录和打卡规则读取,以及 WROAD2/WARCH3 测试账号受控上线巡检。
|
||||
- 暂缓深度镜像:WREAD1、WADM1、WARCH1、WARCH2 已完成请假/补卡可读性、模型设计、最小历史留存和正式只读查询;WARCH3/WCHECK1/WEXT1 本轮只解决巡检稳定、打卡摘要留存和外出/加班 dry-run;`wecom_attendance_approval_mirror_v1`、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1`、`wecom_ruleops_v1` 仍需等 WROAD2 三天验收、WREAL1 小范围真实试用或你明确批准后再拆。
|
||||
- OA 主办边界不变:报销、采购、费用、合同、项目、付款批次、AP、代账交接、审计和高敏附件继续在 OA 内完成,只向企业微信发送脱敏提醒和 OA 链接。
|
||||
|
||||
## 8. 后续使用要求
|
||||
|
||||
+21
-12
@@ -2,10 +2,10 @@
|
||||
|
||||
**文档状态**:当前开发路线图事实源。
|
||||
**分类**:开发治理 / 路线图。
|
||||
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 / WARCH1 / WARCH2 / WROAD2 之后。
|
||||
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2`。
|
||||
**适用时间**:WDRG / WCP1 / WHMSLIVE / WOUA / WAPI / WRRA / WOUA-FIX / WRV1 / WRA1 / WMSG1 / WAPC1 / WAPC2 / WPROC1 / WPROC2 / WAPC2F / WAPC2FLIVE / WCBK1 / WEGOV1 / WACP3 / WACP3R / WREAD1 / WREAD1F / WADM1 / WARCH1 / WARCH2 / WROAD2 / WARCH3 / WCHECK1 / WEXT1 之后。
|
||||
**关联 Issue**:`WDRG,WCP1,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1`。
|
||||
**最近审计**:2026-05-20。
|
||||
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。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 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WARCH1/WARCH2 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明;WARCH2 只提供 OA DB 只读查询 API 和 PC/移动历史页,不调用企微 API、不读取企微 Secret、不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。WROAD2 后当前策略是测试账号受控上线和每日脚本化巡检,允许补偿归档写 OA 自建留存库,但不写企业微信、不安装 cron/systemd timer、不扩大真实员工范围;`WARCH3` 归档稳定化和 `WCHECK1` 打卡摘要留存优先于完整深度镜像,外出/加班先延期。
|
||||
**不得误读**:本文只控制后续开发节奏、阶段顺序和门禁。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 做自建留存、审计、入口、消息和应急兜底”。WACP3R 已将补偿拉取复跑修正为自动按 `SMOKE002` creator 发现 OA-POC 请假/补卡模板,公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`。WREAD1F 新样例后,审批人真机确认请假和补卡消息卡片、列表、详情页均可看清;远端人工结果报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。但同意按钮单击即通过仍是操作风险,需在 WARCH1/WARCH2 的留存、审计、提示或操作规程中继续处理。WADM1 只完成自建留存/镜像数据模型设计;WARCH1 只实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和 QA-only 离线读取证明;WARCH2 只提供 OA DB 只读查询 API 和 PC/移动历史页,不调用企微 API、不读取企微 Secret、不代表完整镜像、外出/加班、应急接管、恢复回写或 RuleOps 已完成。WROAD2 后当前策略是测试账号受控上线和每日脚本化巡检,允许补偿归档写 OA 自建留存库,但不写企业微信、不扩大真实员工范围;WARCH3 将每日巡检升级为服务器 `systemd timer` 与脱敏企微告警,WCHECK1 增加打卡规范化明细、日/月摘要和规则快照留存,WEXT1 只完成外出/加班 dry-run 支持。上述阶段仍不代表完整深度镜像、应急接管、恢复回写或 RuleOps 已完成。
|
||||
|
||||
## 1. 路线图原则
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
| `WADM1` | 自建留存/镜像模型设计已固化 | docs-only 固定 OA 留存域数据模型:企微审批主表、参与人/节点/动作、字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照边界、隐私保留、权限和 WARCH1 最小实现切片。WADM1 解决 WACP3R “回调证据文件未持久化”的模型设计缺口,但不创建迁移、不写入业务数据、不放行完整镜像。 |
|
||||
| `WARCH1` | 最小审批历史留存已实现并公网验证 | 新增 `WecomApprovalArchive` 等审批留存表、回调事件表、补偿拉取 run/item/finding 表;回调 POST 默认写 OA DB,失败返回非 200 让企微重试;补偿 runner 支持 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关写 OA 留存;新增 `npm run check:wecom-approval-archive` 离线读取 OA DB 历史摘要。公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 与 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`:请假/补卡 2 条历史可离线读取,finding 是 WARCH1 前旧回调未入 DB 的历史 gap,已作为对账 finding 留存。范围仅请假/补卡,不写企微、不做完整镜像、不做应急接管或恢复回写。 |
|
||||
| `WARCH2` | 已部署并公网只读验证 | 新增只读 `GET /api/wecom/approval-archive` 和 `GET /api/wecom/approval-archive/:id`,以及 PC/移动共用历史页 `/wecom-approval-archive`、`/mobile/wecom-approval-archive`。查询只读 OA DB,不调用企微 API、不读取企微 Secret;权限固定为本人相关、直属下属和 HR/admin/* 全量,`leave.view_all` / `attendance.view_all` 不单独代表全量企微归档权限;申请人未映射、callback-only、外部-only 记录只允许 HR/admin/* 查看。页面文案固定“企业微信主办,OA 自建留存”“仅已归档记录”,不提供审批动作。公网 `SYNC_ENV=0` 部署已完成,`/ready` 正常,新 API 未登录返回 401;远端离线归档报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 为 `PASS_WITH_FINDINGS`,可读 2 条请假/补卡历史,finding 仍是 WARCH1 前旧回调未入库历史 gap。 |
|
||||
| `WROAD2+OPS` | 受控上线和每日巡检 Day 0 已完成 | 当前进入测试账号受控上线:范围固定 `SMOKE002 -> SMOKE101 + wangshunli`,试用 3 天,每日手动触发一键巡检脚本。巡检复用 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档步骤可用进程级 `WECOM_ARCHIVE_WRITE=1` 写 OA 自建留存库,不写企业微信、不创建模板或审批单、不安装 cron/systemd timer。Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。 |
|
||||
| `WROAD2+OPS` | 受控上线和每日巡检 Day 0 已完成 | 当前进入测试账号受控上线:范围固定 `SMOKE002 -> SMOKE101 + wangshunli`,试用 3 天,每日触发一键巡检脚本。巡检复用 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档步骤可用进程级 `WECOM_ARCHIVE_WRITE=1` 写 OA 自建留存库,不写企业微信、不创建模板或审批单。Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。 |
|
||||
| `WARCH3` | 归档稳定化自动巡检已实现 | 新增 `scripts/qa/run-warch3-ops-daily.sh` 聚合 WROAD2 子报告、归类 observation/finding、解释历史 callback gap,并新增 `deploy/trialrun/install-warch3-ops-timer.sh` 安装服务器 `systemd service/timer`,每天 07:30 触发;巡检失败或 finding 时可通过 `npm run send:wecom-ops-alert` 向 `wangshunli/HYD000006` 发送脱敏企微应用消息。仍只允许补偿归档写 OA DB,不写企业微信、不创建模板或审批单。 |
|
||||
| `WCHECK1` | 打卡摘要留存已实现 | 新增打卡日/月摘要和规则快照留存表、`wecomCheckinArchive` 服务和 `npm run check:wecom-checkin-summary-archive` runner。保存规范化打卡明细、日/月摘要、异常摘要、规则快照摘要和 hash;报告只输出计数、状态、异常摘要和脱敏 userid 前缀,不保存完整位置、人脸、设备明文或原始 payload。 |
|
||||
| `WEXT1` | 外出/加班 dry-run 已实现 | 扩展 `npm run check:wecom-approval-fixtures` 支持 `--target=outing --target=overtime`,只构建 OA-POC 外出/加班最小通用模板和低风险审批申请 payload,仍默认 dry-run。live 创建外出/加班模板或审批单前必须另起问答和 Issue CSV。 |
|
||||
|
||||
## 3. 阶段路线
|
||||
|
||||
@@ -70,10 +73,10 @@
|
||||
| 11 | `WADM1 自建留存/镜像数据模型设计` | 先设计 OA 留存字段、状态机、幂等键、索引、隐私保留、审计和迁移回滚方案。 | WACP3 证明补偿拉取可用;WREAD1 明确模板可读性或回退策略。 | 已完成 docs-only 设计:模型覆盖审批、打卡、人事关键快照、回调事件和补偿拉取记录;只允许在单独 WARCH1 合同批准后进入 schema 或业务代码实现。 |
|
||||
| 12 | `WARCH1 最小自建历史留存实现` | 先保存企微审批最小历史,满足企微 API 不可用时可查看已保存请假/补卡摘要。 | WADM1 完成并批准 schema/接口实现。 | 已新增 OA DB 审批留存、回调事件、补偿 run/item/finding、双开关补偿入库和离线读取 runner;公网已持久化现有请假/补卡详情并离线读取 2 条已通过历史。报告和日志只输出脱敏前缀;不保存 Secret、token、EncodingAESKey、原始密文或无治理原始 payload。 |
|
||||
| 13 | `WARCH2 正式只读历史查询` | 把 WARCH1 runner 级离线证明升级为正式 OA API 和 PC/移动历史页。 | WARCH1 已持久化请假/补卡历史;权限与脱敏合同批准。 | 已完成:查询 API、PC/移动页面、`SYNC_ENV=0` 公网部署、`/ready`、未登录 401、离线归档 runner 均通过。V1 仍只覆盖请假/补卡,不调用企微 API,不提供审批动作。 |
|
||||
| 14 | `WROAD2+OPS 受控上线与每日巡检` | 把 WARCH2 后的能力先放入测试账号受控使用,用每日脚本化巡检验证入口、运行、补偿归档和 OA 离线查询稳定性。 | WARCH2 公网验证完成;用户选择先受控上线、不直接深镜像;允许 `SYNC_ENV=0` 部署和补偿归档写 OA DB。 | Day 0 公网巡检已生成 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json`,结论 `PASS_WITH_FINDINGS`;后续 3 天每日手动触发一次。脚本不写企业微信、不创建样件、不安装定时任务;发现缺配置时真实记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 |
|
||||
| 15 | `WARCH3 归档稳定化` | 稳定每日补偿、归档对账 finding、历史查询可用性、旧 callback gap 解释和报告趋势。 | WROAD2+OPS Day 0 和试用反馈可用。 | 每日归档报告可解释;请假/补卡历史查询在企微 API 不可用时仍可读;重复、漏回调和状态不一致有对账记录。 |
|
||||
| 16 | `WCHECK1 打卡摘要留存` | 只保存打卡日/月摘要、异常摘要和规则快照。 | WROAD2+OPS 或 WARCH3 证明审批归档稳定;打卡只读 API 和权限仍可用。 | OA 可查看必要打卡摘要和异常摘要;不保存完整位置、人脸、设备明文或原始 payload。 |
|
||||
| 17 | `WEXT1 外出/加班延期决策` | 根据真实业务需要决定是否自动生成外出/加班低风险 POC 样件。 | 用户批准或真实业务提出需要。 | 若批准,按 WAPC2F/WREAD1 规则做非自审、低风险、脱敏 POC;若不批准,继续延期,不阻断受控使用。 |
|
||||
| 14 | `WROAD2+OPS 受控上线与每日巡检` | 把 WARCH2 后的能力先放入测试账号受控使用,用每日脚本化巡检验证入口、运行、补偿归档和 OA 离线查询稳定性。 | WARCH2 公网验证完成;用户选择先受控上线、不直接深镜像;允许 `SYNC_ENV=0` 部署和补偿归档写 OA DB。 | Day 0 公网巡检已生成 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json`,结论 `PASS_WITH_FINDINGS`;后续以 WARCH3 systemd timer 承接每日巡检。脚本不写企业微信、不创建样件;发现缺配置时真实记录 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。 |
|
||||
| 15 | `WARCH3 归档稳定化` | 稳定每日补偿、归档对账 finding、历史查询可用性、旧 callback gap 解释和报告趋势。 | WROAD2+OPS Day 0 和用户批准 systemd timer。 | 已新增 WARCH3 汇总脚本、脱敏企微告警和 07:30 systemd timer 安装脚本;每日归档报告可解释,live smoke/OCR 仅作 observation。公网安装和 Day 1/2/3 汇总仍按 WARCH3/WROAD2 证据回填。 |
|
||||
| 16 | `WCHECK1 打卡摘要留存` | 保存规范化打卡明细、日/月摘要、异常摘要和规则快照。 | WROAD2+OPS 或 WARCH3 证明审批归档稳定;打卡只读 API 和权限仍可用。 | 已新增摘要表、规则快照、归档服务和 QA runner;OA 可查看必要打卡摘要和异常摘要;不保存完整位置、人脸、设备明文或原始 payload。 |
|
||||
| 17 | `WEXT1 外出/加班 dry-run` | 自动生成外出/加班低风险 POC 样件 payload,但不 live 创建。 | 用户批准先做 dry-run。 | 已扩展 WAPC2F runner 支持 `outing`、`overtime` dry-run;非自审、Secret 固定和脱敏报告沿用既有门禁。live 创建模板/审批单前必须再次问答批准。 |
|
||||
| 18 | `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA。 | WARCH3、WCHECK1 和外出/加班决策完成;运行优先门禁和用户一致性仍满足。 | OA 可查询企微 HR/考勤镜像、附件摘要、审批历史、考勤解释和审计;不把 OA 主办高敏审批迁到企微。 |
|
||||
| 19 | `oa_hr_emergency_mode_v1` | 企业微信不可用时,由管理员人工开启 OA HR/考勤应急接管。 | 自建历史留存和镜像数据可支撑对账;OA 应急入口、权限和审计方案明确。 | 管理员可填写原因、范围、预计结束时间和责任人后开启/关闭;应急单据带 `EMERGENCY` 标记。 |
|
||||
| 20 | `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微。 | 应急接管模式完成;企微写回接口、冲突规则和回滚策略已单独批准。 | 系统生成成功、失败、跳过和冲突清单;无冲突项可自动回写,冲突进入人工确认。 |
|
||||
@@ -248,7 +251,7 @@ WAPC2 通过后,下一步不是直接进入 `wecom_attendance_approval_mirror_
|
||||
8. `WARCH1 最小自建历史留存实现`:已保存请假/补卡企微审批历史、回调事件和补偿拉取记录。
|
||||
9. `WARCH2 正式只读历史查询`:已提供 OA DB 只读 API 和 PC/移动历史页。
|
||||
10. `WROAD2+OPS 受控上线与每日巡检`:下一步先做测试账号 3 天脚本化巡检,沉淀 Day 0/Day 1/Day 2/Day 3 脱敏报告。
|
||||
11. `WARCH3 / WCHECK1 / WEXT1`:再做归档稳定化、打卡摘要留存和外出/加班延期决策。
|
||||
11. `WARCH3 / WCHECK1 / WEXT1`:再做归档稳定化自动巡检、打卡摘要留存和外出/加班 dry-run。
|
||||
|
||||
WROAD2+OPS、WARCH3、WCHECK1 和外出/加班决策完成前,不拆 `wecom_attendance_approval_mirror_v1`。
|
||||
|
||||
@@ -329,8 +332,14 @@ WARCH2 把 WARCH1 的离线 runner 证明升级为 OA 内可查历史,但仍
|
||||
|
||||
WROAD2+OPS 把 WARCH2 后的可用能力先进入测试账号受控使用,不等同于完整镜像增强:
|
||||
|
||||
- 范围:`SMOKE002 -> SMOKE101 + wangshunli`,3 天试用,每日一次手动触发 `scripts/qa/run-wroad2-ops-daily.sh`。
|
||||
- 范围:`SMOKE002 -> SMOKE101 + wangshunli`,3 天试用,每日一次触发 `scripts/qa/run-wroad2-ops-daily.sh` 或 WARCH3 systemd timer。
|
||||
- 巡检:复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿拉取和 OA 归档离线读取 runner,输出 `artifacts/qa-flow/wroad2-ops-<timestamp>/summary.json`。
|
||||
- 写入边界:巡检默认只读;仅审批补偿归档步骤可用进程级 `WECOM_ARCHIVE_WRITE=1` 与 `--persist-to-db` 写 OA 自建留存库。不写企业微信、不创建模板、不提交审批单、不安装持久定时任务。
|
||||
- 写入边界:巡检默认只读;仅审批补偿归档步骤可用进程级 `WECOM_ARCHIVE_WRITE=1` 与 `--persist-to-db` 写 OA 自建留存库。不写企业微信、不创建模板、不提交审批单。
|
||||
- 结果边界:结论只允许 `PASS_CONTROLLED_OPS_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或真实 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。
|
||||
- 后续顺序:WROAD2+OPS 后优先 `WARCH3` 归档稳定化,再做 `WCHECK1` 打卡摘要留存;外出/加班进入 `WEXT1` 延期决策。上述阶段完成前,不启动完整深度镜像、应急接管、恢复回写或 RuleOps。
|
||||
- 后续顺序:WROAD2+OPS 后优先 `WARCH3` 归档稳定化,再做 `WCHECK1` 打卡摘要留存;外出/加班进入 `WEXT1` dry-run。上述阶段完成前,不启动完整深度镜像、应急接管、恢复回写或 RuleOps。
|
||||
|
||||
### 6.4 WARCH3 / WCHECK1 / WEXT1 当前边界
|
||||
|
||||
- `WARCH3`:`scripts/qa/run-warch3-ops-daily.sh` 聚合 WROAD2 子报告并输出 `artifacts/qa-flow/warch3-ops-<timestamp>/summary.json`;`deploy/trialrun/install-warch3-ops-timer.sh` 安装 `hyb-oa-warch3-ops.service/timer`,每日 07:30 触发。巡检失败或 finding 可用 `npm run send:wecom-ops-alert` 发脱敏企微应用消息,双开关为 `--send` + `WARCH3_OPS_ALERT_SEND=1`。
|
||||
- `WCHECK1`:`WecomCheckinRecord` 继续作为规范化明细;新增日摘要、月摘要和规则快照。`npm run check:wecom-checkin-summary-archive` 默认离线只读;写 OA 留存必须同时具备 `--persist-to-db` + `WECOM_CHECKIN_ARCHIVE_WRITE=1`。报告不输出完整 userid、位置、人脸、设备明文或原始 payload。
|
||||
- `WEXT1`:`npm run check:wecom-approval-fixtures -- --target=outing --target=overtime` 只做 dry-run。外出/加班 live 创建仍需新 Issue CSV 和人工批准,不因请假/补卡已验证而自动放行。
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ WDRG 后,企微优先 HR/考勤路线采用“路线图门禁 + 单功能 Issu
|
||||
|
||||
当前企微优先路线顺序固定为:
|
||||
|
||||
`WECOM-CAPABILITY-POC-V1 -> wecom_hr_master_sync_v1 -> runtime_first_wecom_enablement -> WOUA-FIX / WECOM-RUNTIME-READY-V1 -> WAPC1 审批配置准备 -> WAPC2 请假/补卡只读 POC -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC(真实事件已确认) -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班延期决策 -> wecom_attendance_approval_mirror_v1 -> oa_hr_emergency_mode_v1 -> wecom_recovery_writeback_v1 -> wecom_ruleops_v1`
|
||||
`WECOM-CAPABILITY-POC-V1 -> wecom_hr_master_sync_v1 -> runtime_first_wecom_enablement -> WOUA-FIX / WECOM-RUNTIME-READY-V1 -> WAPC1 审批配置准备 -> WAPC2 请假/补卡只读 POC -> WAPC2F 自动生成 POC 样件 -> WAPC2FLIVE live 创建与 WAPC2 自动复跑 -> WCBK1 回调只读 POC(真实事件已确认) -> WEGOV1 企微主办治理与自建留存路线修正 -> WACP3/WACP3R 补偿拉取 POC -> WREAD1 审批可读性与误审批防护门禁 -> WADM1 自建留存/镜像数据模型设计 -> WARCH1 最小自建历史留存实现 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run -> WREAL1 小范围真实试用 -> wecom_attendance_approval_mirror_v1 -> oa_hr_emergency_mode_v1 -> wecom_recovery_writeback_v1 -> wecom_ruleops_v1`
|
||||
|
||||
允许并行处理线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护;但采购、合同、报销、付款、AP、代账和高敏附件等 OA 主办域必须另开合同,不得混入企微 HR/考勤路线。
|
||||
|
||||
|
||||
+13
-10
@@ -90,7 +90,10 @@
|
||||
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | `CURRENT` | 企微自建留存/镜像数据模型设计已完成:docs-only 固定审批留存、参与人/动作/字段摘要、附件摘要、回调事件、补偿拉取 run/item、对账 finding、打卡/人事快照复用边界、隐私权限和 WARCH1 最小实现切片;不改 Prisma schema、不部署、不写 OA/企微业务数据 |
|
||||
| `WARCH1` | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv` | `CURRENT` | 企微审批最小自建历史留存已实现并部署验证:新增 Prisma 留存表和 migration、回调事件默认入 OA DB、补偿拉取双开关入库、对账 finding 和 `check:wecom-approval-archive` 离线读取 runner;远端补偿持久化 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 为 `PASS_WITH_FINDINGS`,离线读取 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 为 `PASS_WITH_FINDINGS`,请假/补卡 2 条已通过历史可读;finding 为 WARCH1 前旧回调未入 DB 的历史 gap;范围仅请假/补卡,不写企微、不新增正式管理页、不进入完整镜像、应急接管、恢复回写或 RuleOps |
|
||||
| `WARCH2` | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv` | `CURRENT` | 企微审批正式只读历史查询已实现并部署验证:新增列表/详情 API、PC/移动历史页、备用模块入口和移动考勤卡入口;只读 OA DB,不调用企微 API、不读取企微 Secret;分层权限为本人相关、直属下属、HR/admin/*,申请人未映射和 callback-only 记录仅 HR/admin/* 可见;公网 `/ready` 正常,新 API 未登录返回 401,远端离线归档报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 为 `PASS_WITH_FINDINGS` 且可读 2 条请假/补卡历史 |
|
||||
| `WROAD2+OPS` | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv` | `CURRENT` | 受控上线与每日巡检合同已建立并完成 Day 0 公网基线:范围固定测试账号 `SMOKE002 -> SMOKE101 + wangshunli`,3 天每日脚本化触发;脚本复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档可写 OA 留存库,不写企业微信、不安装定时任务;Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,企微 runtime、补偿归档、离线归档读取和隐私扫描通过,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用 |
|
||||
| `WROAD2+OPS` | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv` | `CURRENT` | 受控上线与每日巡检合同已建立并完成 Day 0 公网基线:范围固定测试账号 `SMOKE002 -> SMOKE101 + wangshunli`,3 天每日脚本化触发;脚本复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档可写 OA 留存库,不写企业微信;Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`,企微 runtime、补偿归档、离线归档读取和隐私扫描通过,finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用 |
|
||||
| `WARCH3` | `issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv` | `CURRENT` | 归档稳定化自动巡检合同已实现并公网验证:新增 `scripts/qa/run-warch3-ops-daily.sh` 聚合 WROAD2 子报告、解释 observation/finding,新增 `deploy/trialrun/install-warch3-ops-timer.sh` 安装每日 07:30 systemd timer,新增 `send:wecom-ops-alert` 脱敏企微告警;公网报告 `/app/artifacts/qa-flow/warch3-ops-20260520-054841/summary.json` 为 `PASS_WITH_FINDINGS`,告警 `/app/artifacts/qa-flow/wecom-ops-alert-20260520T054851Z/summary.json` 为 `PASS_ALERT_SENT_WITH_CONFIG_GAPS` |
|
||||
| `WCHECK1` | `issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv` | `CURRENT` | 打卡摘要留存合同已实现并公网验证:新增打卡日/月摘要和规则快照 Prisma 表、`wecomCheckinArchive` 服务和 `check:wecom-checkin-summary-archive` runner;live 报告 `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054907Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_READY`,offline 报告 `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054926Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_OFFLINE_READY`;不保存位置、人脸、设备明文或原始 payload |
|
||||
| `WEXT1` | `issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv` | `CURRENT` | 外出/加班低风险 POC dry-run 合同已实现并公网验证:扩展 `check:wecom-approval-fixtures` 支持 `outing`、`overtime` payload dry-run;公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260520T054940Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY`;不 live 创建模板或审批单,live 前必须另起问答和合同 |
|
||||
|
||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||
|
||||
@@ -98,28 +101,28 @@
|
||||
|
||||
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2` | `rg` | none | 2026-05-20 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;WROAD2 后优先读取企微主办 + OA 自建留存路线、正式只读查询、受控上线巡检和后续 WARCH3/WCHECK1/WEXT1 队列 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2` | `audit-docs` | none | 2026-05-20 | `audit-docs --check-registry` | medium | 判断文档权威性;WROAD2 登记当前受控上线、每日巡检和后续归档稳定化事实 |
|
||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WARCH1,WARCH2,WROAD2` | Issue CSV | `docs/requirements-specification.md` | 2026-05-20 | `rg` | high | WROAD2 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、只读历史查询、每日巡检、WARCH3/WCHECK1/WEXT1 前置和深度镜像边界;ERP 未来不接入 OA |
|
||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | `rg` | none | 2026-05-20 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么;WARCH3/WCHECK1/WEXT1 后优先读取企微主办 + OA 自建留存路线、受控上线巡检、打卡摘要、外出/加班 dry-run 和后续 WREAL1 前置 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1,LAUNCH-AUTO-OPS-V1,AUTO-REMAINING-CLOSEOUT,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | `audit-docs` | none | 2026-05-20 | `audit-docs --check-registry` | medium | 判断文档权威性;登记当前受控上线、自动巡检、打卡摘要留存和外出/加班 dry-run 事实 |
|
||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | Issue CSV | `docs/requirements-specification.md` | 2026-05-20 | `rg` | high | WARCH3/WCHECK1/WEXT1 后定义 HR/考勤企微主办、OA 自建留存、受控测试域、只读历史查询、自动巡检、打卡摘要、外出/加班 dry-run、WREAL1 前置和深度镜像边界;ERP 未来不接入 OA |
|
||||
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WAPC1,WAPC2FLIVE,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | 记录已拍板产品边界、企微/OA 设计门禁、开发路线图、WCBK1 真实回调事件证据、WEGOV1 混合主办边界和 OA 自建留存策略 |
|
||||
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9,WFDG` | Playwright/视觉审查/DG8/DG9/WFDG | none | 2026-05-18 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型;WFDG 后区分 OA 主办、企微镜像和 OA 应急接管 |
|
||||
| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2` | Issue CSV | none | 2026-05-20 | `rg` | high | WROAD2 后固定最小审批历史留存、正式只读历史查询、受控上线巡检、WARCH3 归档稳定化、WCHECK1 打卡摘要和 WEXT1 外出加班决策前置;仍禁止直接进入深度镜像 |
|
||||
| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-20 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;WROAD2 后记录审批补偿和归档巡检只写 OA 留存、不写企业微信的边界 |
|
||||
| `ROADMAP-WECOM` | `docs/企微优先开发路线图.md` | 开发治理 | 企微优先开发路线图事实源 | `CURRENT` | product/dev | `WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | Issue CSV | none | 2026-05-20 | `rg` | high | WARCH3/WCHECK1/WEXT1 后固定最小审批历史留存、正式只读历史查询、受控上线自动巡检、打卡摘要和外出/加班 dry-run;仍禁止直接进入深度镜像 |
|
||||
| `WECOM-API-CATALOG` | `docs/企微API能力目录.md` | 集成事实源 | 企微 API 能力目录和 Secret 矩阵事实源 | `CURRENT` | product/backend/qa | `WAPI,WRRA,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | 官方企业微信开发者中心链接/Issue CSV | none | 2026-05-20 | `rg` | high | 企微开发先读;只保存摘要、链接和项目决策,不保存 Secret、token、完整 userid、手机号、邮箱或原始 payload;记录审批补偿、打卡摘要和巡检只写 OA 留存、不写企业微信的边界 |
|
||||
| `DELIVERY` | `docs/开发交付与变更管理规约.md` | 开发治理 | 开发交付事实源 | `CURRENT` | dev/qa | `GOV8,WDRG,WRRA,WAPC1,WPROC1,WPROC2,WCBK1,WEGOV1` | `rg` | none | 2026-05-19 | `rg` | high | Issue、review、commit、交付规则;WEGOV1 后采用自动化优先、受控测试域、自建留存说明和 WACP3/WREAD1/WADM1/WARCH1 前置门禁 |
|
||||
| `QA-GATE` | `docs/测试规约与回归门禁.md` | 测试验收 | 测试和回归门禁事实源 | `CURRENT` | qa | `QAF8,GOV8,WPROC1,WPROC2,WAPC2FLIVE,WCBK1,WEGOV1` | 回归脚本/Playwright | none | 2026-05-19 | `rg` | high | 测试计划、DONE 门禁、企微三层门禁、自动化优先、非自审、无完整 sandbox 假设、WREAD1 真机可读性和 WARCH1 留存验收门禁 |
|
||||
| `AGENT-PRACTICE` | `docs/Agent开发交付实践.md` | Agent 治理 | Agent 协作事实源 | `CURRENT` | dev | `GOV8,DG8` | Skill 校验 | none | 2026-05-13 | `rg` | medium | Agent 协作开发实践和设计主参考层级 |
|
||||
| `AGENTS` | `AGENTS.md` | 执行硬约束 | 项目硬规则 | `CURRENT` | lead | `GOV8` | 人工核对 | none | 2026-05-13 | `rg` | high | 不可违反约束 |
|
||||
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2` | quick_validate | none | 2026-05-20 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、WEGOV1/WADM1/WARCH1/WARCH2/WROAD2 自建留存和受控巡检边界;不得绕过后续独立合同直接做完整考勤审批镜像 |
|
||||
| `SKILL-HYB` | `.agents/skills/hyb-oa-delivery/SKILL.md` | Skill | 项目交付 Skill | `CURRENT` | dev | `GOV8,DG8,WFDG,WDRG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | quick_validate | none | 2026-05-20 | `quick_validate.py` | medium | 每轮交付操作入口;企微/OA 后续先读 API 能力目录、Secret 矩阵、路线图、WEGOV1/WADM1/WARCH1/WARCH2/WROAD2/WARCH3/WCHECK1/WEXT1 自建留存和受控巡检边界;不得绕过 WREAL1 或后续独立合同直接做完整考勤审批镜像 |
|
||||
| `REQ-WF` | `docs/需求-统一审批中心规约.md` | 需求事实源 | 审批模块规约 | `CURRENT` | product/backend | `AP6,WF8,WFOC,DBWFOC,WFDG,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WREAD1F,WADM1,WARCH1` | workflow 回归 | none | 2026-05-19 | `rg` | high | OA 主办审批、企微主办 HR/考勤审批、OA 自建留存、真实回调事件证据、WACP3R 补偿拉取 findings、WREAD1 请假/补卡真机可读性结论、WARCH1 最小历史留存及 OA 应急接管边界 |
|
||||
| `REQ-OCR` | `docs/需求-报销发票OCR规约.md` | 需求事实源 | OCR 模块规约 | `CURRENT` | product/backend | `RI6,OCR8,BIZ8` | OCR 回归 | none | 2026-05-13 | `rg` | high | OCR 和发票边界 |
|
||||
| `RULE-REIMB` | `docs/报销发票附件规则.md` | 需求事实源 | 报销发票附件规则 | `CURRENT` | product/finance | `RI6,BIZ8` | 报销回归 | none | 2026-05-13 | `rg` | high | 报销、发票、附件细则 |
|
||||
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2` | live smoke/真机反馈/Issue CSV | none | 2026-05-20 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WARCH2 已提供请假/补卡正式只读历史查询,WROAD2 固定测试账号受控上线和每日巡检;采购、合同、项目、报销和财务继续 OA 主办 |
|
||||
| `REQ-WECOM` | `docs/需求-企业微信基础设施规约.md` | 需求事实源 | 企业微信边界规约 | `CURRENT` | product/backend | `R7E,ORG8,WF8,ATTV1,HR-WECOM-DIRECTORY-V1,WFOC,DBWFOC,WFDG,WCP1,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | live smoke/真机反馈/Issue CSV | none | 2026-05-20 | `rg` | high | 企微优先承担 HR/考勤员工日常审批和人事/打卡能力;WARCH2 已提供请假/补卡正式只读历史查询,WARCH3 固定受控上线自动巡检,WCHECK1 固定打卡摘要留存,WEXT1 固定外出/加班 dry-run;采购、合同、项目、报销和财务继续 OA 主办 |
|
||||
| `RUNBOOK-TRIAL` | `docs/试运行与自动化验证手册.md` | 操作手册 | 试运行验证手册 | `CURRENT` | qa/ops | `R7E,QAOPS8` | live readiness | none | 2026-05-13 | `rg` | medium | 本地、公网、企业微信和真实 OCR 验证 |
|
||||
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1` | 人工验收 | none | 2026-05-19 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、OA 主办数据边界、HR 主数据镜像、运行门禁、WCBK1 回调配置步骤、WADM1 自建留存模型和 WARCH1 最小历史留存关注点 |
|
||||
| `MANUAL-ADMIN` | `docs/管理员手册.md` | 操作手册 | 管理员手册 | `CURRENT` | ops/admin | `ORG8,R7E,WFOC,DBWFOC,WFDG,WCPC,WHMS,WHMSLIVE,WOUA,WAPI,WRRA,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WPROC1,WPROC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WACP3,WACP3R,WREAD1,WADM1,WARCH1,WARCH2,WROAD2,WARCH3,WCHECK1,WEXT1` | 人工验收 | none | 2026-05-20 | `rg` | medium | 管理员上线、企微优先边界、Secret 矩阵、WARCH3 自动巡检与脱敏告警、WCHECK1 打卡摘要留存、WEXT1 外出/加班 dry-run 和深度镜像前置关注点 |
|
||||
| `MANUAL-EMPLOYEE` | `docs/员工快速上手.md` | 操作手册 | 员工手册 | `CURRENT` | product | `R7E,WFOC,DBWFOC,WFDG` | 人工验收 | none | 2026-05-18 | `rg` | low | 员工侧使用说明,HR/考勤优先企业微信,采购/合同/财务仍在 OA;区分企微镜像和 OA 应急接管 |
|
||||
| `R7-DRAFT` | `docs/trialrun/R7扩大试运行草案.md` | 草案 | R7 准备材料 | `DRAFT` | product/qa | `R7E` | R7EP 报告 | none | 2026-05-13 | `rg` | medium | 不代表当前 R7E 执行状态 |
|
||||
| `OPS-RUNBOOK` | `docs/trialrun/OPS7运维交接Runbook.md` | 运维草案 | OPS 准备材料 | `DRAFT` | ops | `OPS8,QAOPS8` | 只读检查 | none | 2026-05-13 | `rg` | medium | 不代表恢复演练已执行 |
|
||||
| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1` | artifacts/qa-flow | none | 2026-05-19 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件创建、WCBK1 真实回调事件确认和 WEGOV1 docs-only 路线治理,不直接放行深度镜像 |
|
||||
| `TRIAL-REPORT` | `docs/trialrun/人工试运行报告.md` | 证据材料 | 试运行证据入口 | `EVIDENCE` | qa | `R7EP,OPS8,QAF8,WOUA,WAPI,WOUAFIX,WRV1,WRA1,WMSG1,WAPC1,WAPC2,WAPC2F,WAPC2FLIVE,WCBK1,WEGOV1,WROAD2,WARCH3,WCHECK1,WEXT1` | artifacts/qa-flow | none | 2026-05-20 | `rg` | medium | 只证明发生过;记录 WAPC2FLIVE live 样件、WCBK1 真实回调事件、WEGOV1 路线治理、WROAD2 Day 0、WARCH3 自动巡检、WCHECK1 打卡摘要和 WEXT1 dry-run,不直接放行深度镜像 |
|
||||
| `FBA-AUDIT` | `docs/trialrun/R7E后功能边界审计.md` | 证据材料 | R7E 后功能边界审计 | `EVIDENCE` | product/dev/qa | `FBA,R7E,QAOPS8,P2T` | Issue CSV | none | 2026-05-15 | `rg` | medium | 只作为功能边界、P2 候选和 Issue 草案证据,不直接派工 |
|
||||
| `HISTORY-MATRIX` | `docs/历史功能状态矩阵.md` | 历史材料 | 历史功能索引 | `HISTORICAL` | product | `DCL7` | git 历史 | none | 2026-05-13 | `rg` | low | 只追溯历史 |
|
||||
| `OLD-IMPL-PLAN` | `docs/implementation_plan.md` | 已移除历史 | 早期实施计划 | `REMOVED` | product | `DCL7` | git history | none | 2026-05-13 | manual | low | 从 git 历史恢复 |
|
||||
|
||||
+8
-5
@@ -20,7 +20,7 @@
|
||||
|
||||
## 最近事实源链
|
||||
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检。
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1 -> LAUNCH-AUTO-OPS-V1 -> AUTO-REMAINING-CLOSEOUT -> HR-WECOM-DIRECTORY-V1 -> HRWD-CONTACT-LIVE-TEST -> WCFB -> WFOC -> DBWFOC -> WFDG -> WDRG -> WCP1 -> WCPC -> WHMS -> WHMSLIVE -> WOUA -> WAPI -> WRRA -> WOUA-FIX -> WRV1 -> WRA1 -> WMSG1 -> WAPC1 -> WAPC2 -> WPROC1 -> WPROC2 -> WAPC2F dry-run -> WAPC2FLIVE -> WCBK1 -> WEGOV1 -> WACP3/WACP3R -> WREAD1 自动汇总 -> WREAD1F 新样例 -> WREAD1 真机确认 -> WADM1 自建留存模型设计 -> WARCH1 最小审批历史留存 -> WARCH2 正式只读历史查询 -> WROAD2+OPS 受控上线与每日巡检 -> WARCH3 归档稳定化自动巡检 -> WCHECK1 打卡摘要留存 -> WEXT1 外出/加班 dry-run。
|
||||
|
||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||
@@ -151,7 +151,10 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
| `WADM1` | `issues/2026-05-19_21-55-33-wecom-archive-mirror-data-model-design-v1.csv` | 当前模型设计:企微自建留存/镜像数据模型已固化 | 固定审批留存、回调事件、补偿拉取、对账 finding、打卡/人事快照边界、隐私权限和 WARCH1 最小实现切片;已由 WARCH1 承接实现请假/补卡最小历史留存 | docs-only;不改 Prisma schema、不部署、不写 OA/企微业务数据;完整镜像仍需单独 Issue CSV 和方案批准 |
|
||||
| `WARCH1` | `issues/2026-05-19_22-32-06-wecom-approval-archive-minimum-v1.csv` | 当前最小历史留存实现:请假/补卡审批历史可入 OA DB 并离线读取 | 新增 `WecomApprovalArchive` 等 Prisma 表、回调事件持久化、补偿 run/item/finding、`check:wecom-approval-archive` 离线 runner;补偿入库需 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1`;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 持久化 2 条详情,`/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 离线读取 2 条历史,finding 为旧回调未入库历史 gap | 不写企微数据;不创建模板或审批单;不新增正式管理页;不做外出/加班、完整镜像、应急接管、恢复回写或 RuleOps |
|
||||
| `WARCH2` | `issues/2026-05-20_00-26-04-wecom-approval-archive-query-v1.csv` | 当前正式只读历史查询:企微请假/补卡已归档历史可通过 OA API 和 PC/移动页面查看 | 新增 `/api/wecom/approval-archive` 列表/详情、`/wecom-approval-archive`、`/mobile/wecom-approval-archive`;只读 OA DB,不调用企微 API;分层权限为本人相关、直属下属、HR/admin/*,申请人未映射和 callback-only 记录仅 HR/admin/* 可见;页面固定“企业微信主办,OA 自建留存”“仅已归档记录”;公网报告 `/app/artifacts/qa-flow/wecom-approval-archive-20260520T013610Z/summary.json` 可读 2 条历史 | 不写 OA/企微数据;不读取企微 Secret;不提供审批动作;不做完整镜像、外出/加班、应急接管、恢复回写或 RuleOps |
|
||||
| `WROAD2+OPS` | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv` | 当前受控上线与每日巡检:先用测试账号运行 3 天脚本化巡检,不直接深镜像 | 新增 `scripts/qa/run-wroad2-ops-daily.sh`,复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档步骤可写 OA 留存库;Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS` | 不写企业微信;不创建模板或审批单;不安装定时任务;外出/加班、完整镜像、应急、回写、RuleOps 和 P2 大功能继续冻结 |
|
||||
| `WROAD2+OPS` | `issues/2026-05-20_11-30-22-wroad2-ops-controlled-launch-v1.csv` | 当前受控上线与每日巡检:先用测试账号运行 3 天脚本化巡检,不直接深镜像 | 新增 `scripts/qa/run-wroad2-ops-daily.sh`,复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿和 OA 归档离线读取;仅补偿归档步骤可写 OA 留存库;Day 0 报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS` | 不写企业微信;不创建模板或审批单;外出/加班、完整镜像、应急、回写、RuleOps 和 P2 大功能继续冻结 |
|
||||
| `WARCH3` | `issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv` | 当前归档稳定化自动巡检:把 WROAD2 巡检升级为可安装的服务器定时任务和脱敏告警 | 已安装 `hyb-oa-warch3-ops.timer`,每日 07:30 触发;手动公网报告 `/app/artifacts/qa-flow/warch3-ops-20260520-054841/summary.json` 为 `PASS_WITH_FINDINGS`,告警 `/app/artifacts/qa-flow/wecom-ops-alert-20260520T054851Z/summary.json` 为 `PASS_ALERT_SENT_WITH_CONFIG_GAPS` | 只允许补偿归档写 OA DB;不写企业微信、不创建模板或审批单;`wangshunli` 非员工号别名 gap 已记录,有效管理员 `HYD000006` 已收到脱敏告警 |
|
||||
| `WCHECK1` | `issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv` | 当前打卡摘要留存:企微打卡数据进入 OA 自建历史摘要 | 新增打卡日/月摘要和规则快照表、`wecomCheckinArchive` 服务和 `npm run check:wecom-checkin-summary-archive`;公网 live `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054907Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_READY`,offline `/app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054926Z/summary.json` 为 `PASS_CHECKIN_ARCHIVE_OFFLINE_READY` | 不写企业微信;不保存位置、人脸、设备明文、原始 payload 或密钥;本窗口 0 条打卡明细、2 条规则快照已写 OA 留存 |
|
||||
| `WEXT1` | `issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv` | 当前外出/加班低风险 POC dry-run | 扩展 `npm run check:wecom-approval-fixtures` 支持 `--target=outing --target=overtime`,公网报告 `/app/artifacts/qa-flow/wecom-approval-fixtures-20260520T054940Z/summary.json` 为 `PASS_FIXTURE_DRY_RUN_READY` | 不 live 创建企微模板或审批单;live 前必须另起问答和 Issue CSV;不放行完整镜像 |
|
||||
|
||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||
|
||||
@@ -171,11 +174,11 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
|
||||
| 文档 | 用途 |
|
||||
| --- | --- |
|
||||
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WROAD2 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例、自建留存、受控上线巡检和深度镜像前置口径 |
|
||||
| [海音达OA-MVP需求规格说明书](./海音达OA-MVP需求规格说明书.md) | PRD 主事实源;WARCH3/WCHECK1/WEXT1 后包含企微审批配置、只读 POC、工程化前置、process gate、请假/补卡样例、自建留存、受控上线巡检、打卡摘要、外出/加班 dry-run 和深度镜像前置口径 |
|
||||
| [文档事实源状态矩阵](./文档事实源状态矩阵.md) | 文档分类、删除恢复和冲突规则 |
|
||||
| [产品设计规则](./产品设计规则.md) | PC、移动、企业微信 WebView 设计事实源;DG8 后飞书工作台/飞书审批为唯一主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F/WARCH1/WARCH2/WROAD2 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、消息实发、审批配置、只读 POC、process gate、自动样件 live 证据、真实回调事件证据、自建留存、补偿拉取、可读性真机确认、模型设计、最小历史留存、正式只读查询、受控上线巡检和后续 WARCH3/WCHECK1/WEXT1 排队事实源 |
|
||||
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WARCH1/WARCH2/WROAD2 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、WAPC2F dry-run、WAPC2FLIVE live 创建、WCBK1 回调真实事件 POC、无完整 sandbox 假设、补偿拉取自动发现、WREAD1/WADM1/WARCH1 决策、最小留存、只读查询和 WROAD2 巡检写 OA 留存边界事实源 |
|
||||
| [企微优先开发路线图](./企微优先开发路线图.md) | WDRG/WCP1/WCPC/WHMS/WHMSLIVE/WOUA/WAPI/WRRA/WOUA-FIX/WRV1/WRA1/WMSG1/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WREAD1/WREAD1F/WARCH1/WARCH2/WROAD2/WARCH3/WCHECK1/WEXT1 后企微优先 HR/考勤改造的阶段顺序、准入/退出门禁、并行规则、禁止跳阶段、API/Secret 口径、运行优先、自建留存、受控上线巡检、打卡摘要、外出/加班 dry-run 和后续 WREAL1/镜像前置事实源 |
|
||||
| [企微 API 能力目录](./企微API能力目录.md) | WAPI/WAPC1/WAPC2/WPROC1/WPROC2/WAPC2F/WAPC2FLIVE/WCBK1/WEGOV1/WACP3/WACP3R/WARCH1/WARCH2/WROAD2/WARCH3/WCHECK1/WEXT1 后企微官方 API、Secret 矩阵、权限/可信 IP/回调模板、读写边界、运行优先、审批配置、只读 POC、回调/补偿入库、打卡摘要留存、外出/加班 dry-run 和巡检写 OA 留存边界事实源 |
|
||||
| [产品口径](./产品口径.md) | 已拍板产品边界和业务规则;WEGOV1 后包含混合主办、自建留存、受控测试域、后续阶段顺序和深度镜像前置口径 |
|
||||
| [需求 - 统一审批中心规约](./需求-统一审批中心规约.md) | OA 主办审批、企微主办 HR/考勤员工日常审批镜像、催办、超时提醒、审批效率 |
|
||||
| [报销发票附件规则](./报销发票附件规则.md) | 报销、发票、OCR、附件细则 |
|
||||
|
||||
+3
-3
@@ -56,7 +56,7 @@
|
||||
| 恢复回写层 | OA + 企业微信 API | 企微恢复后由 `wecom_recovery_writeback_v1` 对应急数据执行 dry-run、自动回写、失败重试和冲突清单。 |
|
||||
| 财务主办层 | OA | 报销、PDF 发票 OCR、发票查重、付款批次、银行回单、AP 台账、代账交接继续 OA 主办,不迁移到企微。 |
|
||||
|
||||
无痛切换是长期目标,不代表本轮已实现完整替代。完整镜像按域定义:企微主办域镜像到 OA;OA 主办域不反向全量镜像到企微,只发送脱敏标题、业务类型、业务编号、状态和 OA 链接。WEGOV1 后,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须保存企微关键历史、完整外部关联键和必要快照,保证企微不可用时可查看既有数据。WROAD2 后“可受控使用”和“完整镜像增强”分离:先用测试账号 `SMOKE002 -> SMOKE101 + wangshunli` 做 3 天每日脚本化巡检,允许补偿归档写 OA 自建留存库,不写企业微信;外出/加班延期,打卡留存 V1 摘要优先,图片 OCR、复杂 BPM、预算、ERP、银行流水导入等 P2 大功能继续冻结。后续必须按 `wecom_hr_master_sync_v1`、`runtime_first_wecom_enablement`、`WOUA-FIX / WECOM-RUNTIME-READY-V1`、`WAPC1 审批配置准备`、`WAPC2 请假/补卡只读 POC`、`WAPC2F 自动生成 POC 样件`、`WAPC2FLIVE live 创建与 WAPC2 自动复跑`、`WCBK1 回调只读 POC(真实事件已确认)`、`WEGOV1 企微主办治理与自建留存路线修正`、`WACP3/WACP3R 补偿拉取 POC`、`WREAD1 审批可读性与误审批防护门禁`、`WADM1 自建留存/镜像数据模型设计`、`WARCH1 最小自建历史留存实现`、`WARCH2 正式只读历史查询`、`WROAD2+OPS 受控上线与每日巡检`、`WARCH3 归档稳定化`、`WCHECK1 打卡摘要留存`、`WEXT1 外出/加班延期决策`、`wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 和 `wecom_ruleops_v1` 分阶段拆 Issue CSV。
|
||||
无痛切换是长期目标,不代表本轮已实现完整替代。完整镜像按域定义:企微主办域镜像到 OA;OA 主办域不反向全量镜像到企微,只发送脱敏标题、业务类型、业务编号、状态和 OA 链接。WEGOV1 后,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须保存企微关键历史、完整外部关联键和必要快照,保证企微不可用时可查看既有数据。WROAD2 后“可受控使用”和“完整镜像增强”分离:先用测试账号 `SMOKE002 -> SMOKE101 + wangshunli` 做 3 天每日脚本化巡检,允许补偿归档写 OA 自建留存库,不写企业微信;WARCH3 改为 07:30 自动巡检,WCHECK1 优先做打卡摘要留存,WEXT1 只做外出/加班 dry-run,图片 OCR、复杂 BPM、预算、ERP、银行流水导入等 P2 大功能继续冻结。后续必须按 `wecom_hr_master_sync_v1`、`runtime_first_wecom_enablement`、`WOUA-FIX / WECOM-RUNTIME-READY-V1`、`WAPC1 审批配置准备`、`WAPC2 请假/补卡只读 POC`、`WAPC2F 自动生成 POC 样件`、`WAPC2FLIVE live 创建与 WAPC2 自动复跑`、`WCBK1 回调只读 POC(真实事件已确认)`、`WEGOV1 企微主办治理与自建留存路线修正`、`WACP3/WACP3R 补偿拉取 POC`、`WREAD1 审批可读性与误审批防护门禁`、`WADM1 自建留存/镜像数据模型设计`、`WARCH1 最小自建历史留存实现`、`WARCH2 正式只读历史查询`、`WROAD2+OPS 受控上线与每日巡检`、`WARCH3 归档稳定化自动巡检`、`WCHECK1 打卡摘要留存`、`WEXT1 外出/加班 dry-run`、`WREAL1 小范围真实试用`、`wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)、`oa_hr_emergency_mode_v1`、`wecom_recovery_writeback_v1` 和 `wecom_ruleops_v1` 分阶段拆 Issue CSV。
|
||||
|
||||
### 1.5 DBWFOC 数据边界修正
|
||||
|
||||
@@ -77,7 +77,7 @@ WFDG 将 DBWFOC 后的产品方向固化为开发前设计门禁。本节只代
|
||||
| 报销、采购、合同、项目、付款、AP、代账 | OA | 主办提交、审批、附件、导出、付款批次、审计和代账资料 | 只发送脱敏消息和 OA deeplink | 采购单附件、合同正文、报价、供应商资料、发票、银行回单和代账资料不进入企微原生审批或企微附件 |
|
||||
| RuleOps | OA + 企业微信 API | AI 草拟、差异报告、dry-run、人工确认、审计和回滚建议 | 接收经确认的审批模板、打卡规则、排班、假期余额或人事字段配置写入 | AI 不得直接改正式规则;每次写入保存前后快照、确认人、API `errcode` 和失败原因 |
|
||||
|
||||
`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像;WOUA-FIX 已将 7 个缺企微测试账号软归档并复检 `oaActiveMissingInWecom=0`,企微缺字段先接受不补;WMSG1 已证明真实企微应用消息和 Deeplink 可用;WAPC1 已固化审批配置准备清单;WAPC2 已提供请假/补卡只读 POC runner;WAPC2FLIVE 已创建 OA-POC 样件并自动复跑通过;WCBK1 已完成真实回调 URL 验证和审批状态事件确认;WACP3R 已自动发现 OA-POC 请假/补卡模板并证明补偿拉取和详情读取可用;WPROC1/WPROC2 已固定自动化优先、非自审、高风险 POC 前置和 process gate;WEGOV1 固定企微主办 + OA 自建留存路线;WARCH1/WARCH2 已完成请假/补卡最小留存和正式只读查询。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC(真实事件已确认)` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班延期决策` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||
`WECOM-CAPABILITY-POC-V1` / WCPC 已按 `CAN_REUSE_NOW`、`NEEDS_CONFIG`、`NEEDS_API_POC`、`KEEP_OA_PRIMARY`、`NOT_SUPPORTED_BY_API` 输出人事助手、通讯录、打卡、审批、假期余额、回调和权限配置能力矩阵,并达到 HR 主数据镜像准入。WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;WAPI 已固定 API/Secret 口径并复跑 WOUA;WRRA 后明确 `PASS_WITH_FINDINGS` 不能直接放行深度镜像;WOUA-FIX 已将 7 个缺企微测试账号软归档并复检 `oaActiveMissingInWecom=0`,企微缺字段先接受不补;WMSG1 已证明真实企微应用消息和 Deeplink 可用;WAPC1 已固化审批配置准备清单;WAPC2 已提供请假/补卡只读 POC runner;WAPC2FLIVE 已创建 OA-POC 样件并自动复跑通过;WCBK1 已完成真实回调 URL 验证和审批状态事件确认;WACP3R 已自动发现 OA-POC 请假/补卡模板并证明补偿拉取和详情读取可用;WPROC1/WPROC2 已固定自动化优先、非自审、高风险 POC 前置和 process gate;WEGOV1 固定企微主办 + OA 自建留存路线;WARCH1/WARCH2 已完成请假/补卡最小留存和正式只读查询;WARCH3/WCHECK1/WEXT1 补齐自动巡检、打卡摘要和外出/加班 dry-run。生产写接口必须单独批准。正式开发顺序为 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC(真实事件已确认)` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化自动巡检` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班 dry-run` -> `WREAL1 小范围真实试用` -> `wecom_attendance_approval_mirror_v1`(承接旧口径 `wecom_native_approval_mirror_v1`)-> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||
|
||||
### 1.7 WDRG 开发路线图门禁
|
||||
|
||||
@@ -90,7 +90,7 @@ WDRG 将 WFDG 后的设计门禁转化为开发节奏门禁。本节只代表路
|
||||
| `runtime_first_wecom_enablement` | 优先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手读取、打卡读取和只读对账 | API/Secret 口径固定,WOUA 差异已分类并有处理决策 |
|
||||
| `WOUA-FIX / WECOM-RUNTIME-READY-V1` | 收口 OA/企微用户一致性差异和运行优先准入 | 区分真实员工、测试账号、待补企微字段、待清理绑定和 WHMS 批量窗口决策 |
|
||||
| `WAPC2FLIVE -> WCBK1 -> WACP3/WACP3R -> WREAD1 -> WADM1 -> WARCH1 -> WARCH2` | 先用请假/补卡低风险样例证明审批详情、回调、补偿拉取、可读性、自建留存和只读查询 | 已完成请假/补卡最小链路;仍不代表完整镜像、外出/加班、应急接管或恢复回写 |
|
||||
| `WROAD2+OPS -> WARCH3 -> WCHECK1 -> WEXT1` | 先测试账号受控上线和每日巡检,再做归档稳定化、打卡摘要留存和外出/加班延期决策 | 3 天巡检报告和归档/打卡摘要边界明确后,再评估完整镜像 |
|
||||
| `WROAD2+OPS -> WARCH3 -> WCHECK1 -> WEXT1 -> WREAL1` | 先测试账号受控上线和每日巡检,再做归档稳定化自动巡检、打卡摘要留存、外出/加班 dry-run 和小范围真实试用准备 | 3 天巡检报告和归档/打卡摘要边界明确后,再评估完整镜像 |
|
||||
| `wecom_attendance_approval_mirror_v1` | 企微打卡、请假、补卡、外出、加班、假期余额和审批历史镜像到 OA | WARCH3、WCHECK1 和外出/加班决策完成后再拆;OA 可查询企微 HR/考勤镜像、考勤解释和审计 |
|
||||
| `oa_hr_emergency_mode_v1` | 企业微信不可用时管理员人工开启 OA HR/考勤应急接管 | 应急单据带 `EMERGENCY` 标记,开启/关闭均有原因、范围、责任人和审计 |
|
||||
| `wecom_recovery_writeback_v1` | 企微恢复后,OA 应急数据先 dry-run,再回写企微 | 生成成功、失败、跳过和冲突清单,不静默覆盖企微正式记录 |
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
# hyb-oa 管理员手册(10 人以下团队版)
|
||||
|
||||
**当前状态(2026-05-20 / WROAD2 Day 0 后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F、WADM1、WARCH1 和 WARCH2 均已收口到当前开发阶段;WROAD2+OPS 已完成测试账号受控上线 Day 0 巡检,报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`;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`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WARCH1 已新增并部署请假/补卡最小审批历史留存:回调事件默认入 OA DB,补偿拉取可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 留存,离线 runner 可不调用企微 API 读取已保存历史摘要;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 和 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`,2 条请假/补卡历史可离线读取,finding 是旧回调未入库历史 gap。WARCH2 已新增只读历史查询 API 和 PC/移动历史页:页面入口为“企微审批历史”,文案固定“企业微信主办,OA 自建留存”和“仅已归档记录”,只读 OA DB,不调用企微 API,不提供审批动作;权限按本人相关、直属下属和 HR/admin/* 分层,申请人未映射或 callback-only 记录仅 HR/admin/* 可见。WROAD2 巡检只允许补偿归档写 OA 留存库,不写企业微信、不创建模板或审批单、不安装 cron/systemd timer;外出/加班延期,打卡留存 V1 摘要优先。同意单击即通过风险已进入归档审计事实,后续正式历史页和操作规程继续突出动作来源与补偿一致性。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
||||
**当前状态(2026-05-20 / WARCH3/WCHECK1/WEXT1 后)**:R7E、PRELAUNCH、财务闭环、ATTV1、HR-WECOM-DIRECTORY-V1、WAPI、WOUA-FIX、WRV1/WRA1、WMSG1、WAPC1/WAPC2、WPROC1/WPROC2、WAPC2F dry-run、WAPC2FLIVE、WCBK1、WEGOV1、WACP3/WACP3R、WREAD1/WREAD1F、WADM1、WARCH1 和 WARCH2 均已收口到当前开发阶段;WROAD2+OPS 已完成测试账号受控上线 Day 0 巡检,报告 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json` 为 `PASS_WITH_FINDINGS`;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`。WACP3R 已新增自动发现模板 ID 路径并公网只读复跑,报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T115944Z/summary.json` 为 `PASS_WITH_FINDINGS`;WREAD1F 新样例后,用户确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`。WARCH1 已新增并部署请假/补卡最小审批历史留存:回调事件默认入 OA DB,补偿拉取可在 `--persist-to-db` + `WECOM_ARCHIVE_WRITE=1` 双开关下写 OA 留存,离线 runner 可不调用企微 API 读取已保存历史摘要;公网报告 `/app/artifacts/qa-flow/wecom-approval-compensation-poc-20260519T160306Z/summary.json` 和 `/app/artifacts/qa-flow/wecom-approval-archive-20260519T160330Z/summary.json` 均为 `PASS_WITH_FINDINGS`,2 条请假/补卡历史可离线读取,finding 是旧回调未入库历史 gap。WARCH2 已新增只读历史查询 API 和 PC/移动历史页:页面入口为“企微审批历史”,文案固定“企业微信主办,OA 自建留存”和“仅已归档记录”,只读 OA DB,不调用企微 API,不提供审批动作;权限按本人相关、直属下属和 HR/admin/* 分层,申请人未映射或 callback-only 记录仅 HR/admin/* 可见。WARCH3 已新增 07:30 systemd timer 巡检和脱敏企微告警;WCHECK1 已新增打卡规范化明细、日/月摘要和规则快照留存;WEXT1 已支持外出/加班 dry-run,但未 live 创建模板或审批单。巡检只允许补偿归档和打卡摘要写 OA 留存库,不写企业微信、不扩大真实员工。同意单击即通过风险已进入归档审计事实,后续正式历史页和操作规程继续突出动作来源与补偿一致性。采购、费用、合同、项目、报销、付款、AP、代账交接和高敏附件继续 OA 主办,企业微信只做脱敏提醒和 OA deeplink。公网 `payment_batch_v1=force_on`,`erp_sync=force_off`。公司当前无 ERP/独立财务系统,且 ERP 未来不接入 OA;财务闭环以 OA 内审批、台账、付款批次、银行对公付款回单和月度代账交接为准。
|
||||
|
||||
## 1. 适用范围
|
||||
- 适用于 `small_team_internal` 工作区模式。
|
||||
@@ -205,7 +205,7 @@ WCP1/WCPC/WHMS 后续推进:
|
||||
2. WCP1 历史结论为 `PAUSE_FOR_CONFIG`;WCPC 已用既有在职测试号完成远端只读复跑,报告 `/tmp/wecom-capability-poc-wcpc-smoke002/summary.json`,结论 `GO_TO_HR_MASTER_SYNC`。
|
||||
3. WCPC 已证明 HR 主数据镜像准入项可用:通讯录成员读取、人事助手花名册读取、打卡短窗口记录/规则读取和假期余额读取均达到可复用状态;审批模板、审批实例和回调配置仍是后续考勤/审批镜像前置项。
|
||||
4. WHMS 已实现并部署验证 `wecom_hr_master_sync_v1`,包括同步批次、逐员工结果、dry-run/apply、People 管理入口和 QA-only runner;公网验证后最终保持 `force_off`。
|
||||
5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班延期决策` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。WAPC2FLIVE 已生成请假/补卡低风险样例,WCBK1 已证明真实审批回调 URL、验签/解密和事件脱敏留证,WACP3R 已证明请假/补卡补偿拉取和详情读取可用,WREAD1 已确认请假/补卡可读性,WADM1 已明确自建留存模型,WARCH1 已实现最小请假/补卡历史留存,WARCH2 已实现正式只读查询;后续仍不得跳过 WROAD2+OPS、WARCH3、WCHECK1、外出/加班决策等独立合同直接进入深度考勤/审批镜像。
|
||||
5. 正式开发仍按 `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化自动巡检` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班 dry-run` -> `WREAL1 小范围真实试用` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1` 分合同实施;WOUA-FIX 已收口测试账号差异和处理决策,WRV1/WRA1/WMSG1 已验证运行入口、OAuth/绑定、脱敏消息、Deeplink、通讯录、人事助手和打卡读取主链路。WAPC2FLIVE 已生成请假/补卡低风险样例,WCBK1 已证明真实审批回调 URL、验签/解密和事件脱敏留证,WACP3R 已证明请假/补卡补偿拉取和详情读取可用,WREAD1 已确认请假/补卡可读性,WADM1 已明确自建留存模型,WARCH1 已实现最小请假/补卡历史留存,WARCH2 已实现正式只读查询;后续仍不得跳过 WROAD2+OPS、WARCH3、WCHECK1、WEXT1/WREAL1 等独立合同直接进入深度考勤/审批镜像。
|
||||
6. 任何生产写接口、企微规则写入、应急模式开启或恢复回写都必须有单独批准、dry-run 或可审计记录。
|
||||
|
||||
WCPC 配置复跑清单:
|
||||
@@ -322,15 +322,19 @@ WAPC2 请假/补卡只读 POC 操作口径:
|
||||
6. 管理员查看审计日志中是否有异常导出、权限拒绝或付款动作。
|
||||
7. 记录当天新增问题、影响等级、处理人、结论和是否需要暂停。
|
||||
|
||||
WROAD2+OPS 企微受控上线每日巡检:
|
||||
WROAD2/WARCH3 企微受控上线每日巡检:
|
||||
|
||||
1. 试用范围固定为测试账号 `SMOKE002 -> SMOKE101 + wangshunli`,试用周期 3 天,每日手动触发一次脚本,不安装 cron 或 systemd timer。
|
||||
2. 本地或远端执行 `scripts/qa/run-wroad2-ops-daily.sh`,报告输出到 `artifacts/qa-flow/wroad2-ops-<timestamp>/summary.json`。
|
||||
3. 巡检会复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿拉取和 OA 归档离线读取;默认不写企业微信。
|
||||
4. 唯一允许写入的是审批补偿归档步骤:进程级 `WECOM_ARCHIVE_WRITE=1` 加 runner `--persist-to-db`,仅写 OA 自建留存库,用于补齐历史和对账证据。
|
||||
5. 结论只能记录为 `PASS_CONTROLLED_OPS_READY`、`PASS_WITH_FINDINGS`、`PAUSED_MANUAL_CONFIRMATION` 或真实 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。出现 Secret、权限、可信 IP、可见范围或外部配置问题时,先记录真实阻断,不扩大范围。
|
||||
6. 外出/加班、完整镜像、应急接管、恢复回写、RuleOps、图片 OCR、复杂 BPM、预算、ERP 和银行流水导入在试用期继续冻结。
|
||||
7. Day 0 公网报告为 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json`,结论 `PASS_WITH_FINDINGS`:企微 runtime、补偿归档、离线归档读取和隐私扫描通过;finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。
|
||||
1. 试用范围固定为测试账号 `SMOKE002 -> SMOKE101 + wangshunli`,试用周期 3 天;WARCH3 可通过 `hyb-oa-warch3-ops.timer` 每天 07:30 自动触发。
|
||||
2. 手动执行命令:`bash scripts/qa/run-warch3-ops-daily.sh`;原始 WROAD2 编排仍可用 `bash scripts/qa/run-wroad2-ops-daily.sh`。
|
||||
3. systemd 安装/卸载:`bash deploy/trialrun/install-warch3-ops-timer.sh`;卸载用 `bash deploy/trialrun/install-warch3-ops-timer.sh uninstall`。
|
||||
4. 巡检会复用公网 `/ready`、live smoke、feature flag、企微 runtime、审批补偿拉取和 OA 归档离线读取;默认不写企业微信。
|
||||
5. 唯一允许写入的是审批补偿归档步骤:进程级 `WECOM_ARCHIVE_WRITE=1` 加 runner `--persist-to-db`,仅写 OA 自建留存库,用于补齐历史和对账证据。
|
||||
6. 每日报告位于 `artifacts/qa-flow/warch3-ops-<timestamp>/summary.json` 或 `artifacts/qa-flow/wroad2-ops-<timestamp>/summary.json`;出现 Secret、权限、可信 IP、可见范围或外部配置问题时,先记录真实阻断,不扩大范围。
|
||||
7. 告警命令为 `cd server && npm run send:wecom-ops-alert -- --summary-file=<summary.json> --send`,真实发送还需 `WARCH3_OPS_ALERT_SEND=1`;消息只包含阶段、状态、finding 数、报告路径和下一步建议。
|
||||
8. WCHECK1 打卡摘要命令:`cd server && npm run check:wecom-checkin-summary-archive -- --offline --format=json`;如要写 OA 留存,必须 `WECOM_CHECKIN_ARCHIVE_WRITE=1` 且带 `--live --persist-to-db`。
|
||||
9. WEXT1 外出/加班 dry-run:`cd server && npm run check:wecom-approval-fixtures -- --format=json --target=outing --target=overtime`;live 创建必须另起问答和合同。
|
||||
10. 外出/加班 live、完整镜像、应急接管、恢复回写、RuleOps、图片 OCR、复杂 BPM、预算、ERP 和银行流水导入在试用期继续冻结。
|
||||
11. Day 0 公网报告为 `/app/artifacts/qa-flow/wroad2-ops-20260520-041941/summary.json`,结论 `PASS_WITH_FINDINGS`:企微 runtime、补偿归档、离线归档读取和隐私扫描通过;finding 为缺 live smoke 登录凭据/pending OCR 子检查异常以及历史 callback gap,不阻断测试账号受控试用。
|
||||
|
||||
问题登记最小字段:
|
||||
|
||||
@@ -453,7 +457,7 @@ WROAD2+OPS 企微受控上线每日巡检:
|
||||
- `WECOM_ALLOWED_RETURN_HOSTS=oa.hyinda.com`
|
||||
5. 先让管理员用账号密码登录,在 `个人设置` 完成一次绑定,再测试企业微信工作台直达;已绑定用户从手机企业微信进入时应直接看到移动工作台。
|
||||
6. 若启用 OA 同步企业微信打卡数据,在企业微信打卡 API「可调用接口的应用」中授权 `海音达工作台`。OA 默认可复用 `WECOM_AGENT_SECRET`;只有单独创建考勤同步应用时才额外配置 `WECOM_CHECKIN_SECRET`。
|
||||
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WROAD2 先用测试账号做 3 天每日巡检;同意单击即通过需继续纳入操作提示和审计设计。外出/加班模板延期,需后续单独配置或 POC。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
|
||||
7. HR/考勤员工日常审批后续配置企业微信原生审批模板作为主流程;WAPC2FLIVE 已自动生成请假/补卡 OA-POC 低风险样件并复跑只读 POC,WCBK1 已完成真实回调事件确认,WACP3R 已完成补偿拉取,WREAD1 已确认请假/补卡在企微中可读,WADM1 已完成自建留存/镜像模型设计,WARCH1 已完成最小请假/补卡历史留存,WARCH2 已完成正式只读历史查询。WARCH3/WCHECK1/WEXT1 后先做自动巡检、打卡摘要留存和外出/加班 dry-run;同意单击即通过需继续纳入操作提示和审计设计。外出/加班 live 模板仍需后续单独批准。报销、财务、采购、合同和项目类 OA 主办审批仍使用企业微信消息提醒并回到 OA 页面完成。
|
||||
|
||||
### 员工会看到的典型状态
|
||||
- 未绑定:登录页会提示“当前企业微信账号尚未绑定”,回退到账号密码登录。
|
||||
|
||||
+6
-6
@@ -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 保存关键历史和外部关联键”的路线。WACP3R 已自动发现 OA-POC 请假/补卡模板并完成公网补偿拉取复跑,报告为 `PASS_WITH_FINDINGS`;补偿拉取和详情读取可用,当时 finding 为回调证据文件未持久化。WREAD1F 后,用户真机确认请假/补卡审批卡片、列表和详情页可读,远端报告为 `PASS_READABLE`;同意单击即通过作为操作风险进入后续设计。WADM1 已完成自建留存/镜像数据模型设计,WARCH1 已创建最小审批留存迁移和运行时入库路径,WARCH2 已提供 OA DB 正式只读查询 API 和 PC/移动历史页。WROAD2 后先做测试账号受控上线和每日巡检,外出/加班延期,打卡留存 V1 摘要优先;后续仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
|
||||
本规约细化企业微信在 `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 保存关键历史和外部关联键”的路线。WACP3R 已自动发现 OA-POC 请假/补卡模板并完成公网补偿拉取复跑,报告为 `PASS_WITH_FINDINGS`;补偿拉取和详情读取可用,当时 finding 为回调证据文件未持久化。WREAD1F 后,用户真机确认请假/补卡审批卡片、列表和详情页可读,远端报告为 `PASS_READABLE`;同意单击即通过作为操作风险进入后续设计。WADM1 已完成自建留存/镜像数据模型设计,WARCH1 已创建最小审批留存迁移和运行时入库路径,WARCH2 已提供 OA DB 正式只读查询 API 和 PC/移动历史页。WROAD2 后先做测试账号受控上线和每日巡检;WARCH3 已新增服务器 07:30 systemd timer 和脱敏企微告警;WCHECK1 已新增打卡摘要留存;WEXT1 已新增外出/加班 dry-run。后续仍不能直接进入完整审批镜像、应急接管、恢复回写或 RuleOps。
|
||||
|
||||
---
|
||||
|
||||
@@ -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 主办财务、采购、合同、项目和报销审批不迁移 | `WARCH2_QUERY_READY / WROAD2_CONTROLLED_OPS`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证;WACP3R 已通过 `getapprovalinfo` 自动发现模板并拉取请假/补卡详情;WARCH1 已补齐回调事件、补偿拉取和对账 finding 的最小 OA 自建留存;WARCH2 已提供只读历史查询。WROAD2 只做测试账号受控上线和每日巡检;完整镜像、外出/加班、应急接管和恢复回写仍需后续合同 |
|
||||
| 审批 | 模板详情、提交审批、审批状态回调、审批详情、模板创建/更新、批量获取审批单号、假期余额 | HR/考勤员工日常审批企微主办,OA 同步镜像;OA 主办财务、采购、合同、项目和报销审批不迁移 | `WARCH3/WEXT1_CONTROLLED_OPS`:WAPC2FLIVE 已创建请假/补卡 OA-POC 样件并复跑通过;WCBK1 已证明审批状态回调 URL 验证、验签、解密和真实事件脱敏留证;WACP3R 已通过 `getapprovalinfo` 自动发现模板并拉取请假/补卡详情;WARCH1 已补齐回调事件、补偿拉取和对账 finding 的最小 OA 自建留存;WARCH2 已提供只读历史查询;WARCH3 已自动巡检和告警;WEXT1 只完成外出/加班 dry-run。完整镜像、外出/加班 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` |
|
||||
@@ -235,9 +235,9 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,企业微信打卡边界
|
||||
## 7.8 WDRG 开发路线图门禁
|
||||
|
||||
- WDRG 后,企业微信相关运行时迁移采用“路线图门禁 + 单功能 Issue CSV”,路线图事实源为 [企微优先开发路线图](./企微优先开发路线图.md)。
|
||||
- WROAD2 后阶段顺序调整为 `WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班延期决策` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||
- WROAD2 后阶段顺序调整为 `WECOM-CAPABILITY-POC-V1` -> `wecom_hr_master_sync_v1` -> `runtime_first_wecom_enablement` -> `WOUA-FIX / WECOM-RUNTIME-READY-V1` -> `WAPC1 审批配置准备` -> `WAPC2 请假/补卡只读 POC` -> `WAPC2F 自动生成 POC 样件` -> `WAPC2FLIVE live 创建与 WAPC2 自动复跑` -> `WCBK1 回调只读 POC` -> `WEGOV1 企微主办治理与自建留存路线修正` -> `WACP3/WACP3R 补偿拉取 POC` -> `WREAD1 审批可读性与误审批防护门禁` -> `WADM1 自建留存/镜像数据模型设计` -> `WARCH1 最小自建历史留存实现` -> `WARCH2 正式只读历史查询` -> `WROAD2+OPS 受控上线与每日巡检` -> `WARCH3 归档稳定化自动巡检` -> `WCHECK1 打卡摘要留存` -> `WEXT1 外出/加班 dry-run` -> `WREAL1 小范围真实试用` -> `wecom_attendance_approval_mirror_v1` -> `oa_hr_emergency_mode_v1` -> `wecom_recovery_writeback_v1` -> `wecom_ruleops_v1`。
|
||||
- `WECOM-CAPABILITY-POC-V1` 已由 WCPC 收口,WHMS/WHMSLIVE 已完成 HR 主数据镜像实现和受控公网验证;后续不得跳过运行优先门禁和 WOUA 差异处理决策直接开发企微 HR/考勤审批镜像、OA 应急接管、恢复回写或 RuleOps 正式写入。
|
||||
- 运行优先阶段先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 `oaActiveMissingInWecom=0`;剩余企微缺字段先接受不补,归档测试绑定保留审计。WRV1 用 `server/src/scripts/check-wecom-runtime-ready.js` 固化运行优先门禁,WRA1 修正 QA-only 打卡只读门禁,WMSG1 完成真实消息和 Deeplink 真机确认。报告结论只能是 `PASS_RUNTIME_READY`、`PASS_WITH_CONFIG_GAPS` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。该结论仍不能直接放行深度镜像,深度考勤/审批镜像需在 WAPC1、WAPC2、WAPC2F/WAPC2FLIVE、WCBK1、WEGOV1、WACP3、WREAD1、WADM1、WARCH1、WARCH2、WROAD2+OPS、WARCH3、WCHECK1 和外出/加班决策完成后再拆。
|
||||
- 运行优先阶段先利用入口、免登/绑定、脱敏消息、deeplink、通讯录 userid 清单、人事助手花名册读取和打卡读取;WOUA-FIX 已按用户确认软归档 7 个缺企微测试账号,复检 `oaActiveMissingInWecom=0`;剩余企微缺字段先接受不补,归档测试绑定保留审计。WRV1 用 `server/src/scripts/check-wecom-runtime-ready.js` 固化运行优先门禁,WRA1 修正 QA-only 打卡只读门禁,WMSG1 完成真实消息和 Deeplink 真机确认。报告结论只能是 `PASS_RUNTIME_READY`、`PASS_WITH_CONFIG_GAPS` 或 `SKIP/BLOCKED_BY_EXTERNAL_CONFIG`。该结论仍不能直接放行深度镜像,深度考勤/审批镜像需在 WAPC1、WAPC2、WAPC2F/WAPC2FLIVE、WCBK1、WEGOV1、WACP3、WREAD1、WADM1、WARCH1、WARCH2、WROAD2+OPS、WARCH3、WCHECK1、WEXT1 和 WREAL1 或明确批准后再拆。
|
||||
- WAPC2F 自动样件默认申请人 `SMOKE002`、审批人 `SMOKE101`;两者必须 active、已绑定企微、可见、不是同一 OA 员工且不是同一企微 userid。`SMOKE101` 不可用时不自动换人,返回配置缺口并问答确认。
|
||||
- 线上缺陷、正式使用阻断、真实反馈扫尾和 QA-only 脚本维护可另开合同并行;采购、合同、报销、付款、AP、代账和高敏附件继续 OA 主办,不混入企微 HR/考勤路线。
|
||||
- 本路线图只代表开发节奏和门禁,不代表任何企微 HR/考勤镜像、OA 应急接管、恢复回写或 RuleOps 运行时能力已完成。
|
||||
@@ -395,7 +395,7 @@ WAPC2 将审批配置准备先收窄到请假和补卡两类低风险链路,
|
||||
|
||||
## 7.15 WADM1/WARCH1 自建留存基础设施
|
||||
|
||||
WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化关键历史,再增强镜像查询”。WARCH1 已把请假/补卡最小审批历史留存落到 Prisma schema、回调入库、补偿拉取入库和 QA-only 离线读取 runner;WARCH2 已把已归档历史开放为正式只读 API 和 PC/移动历史页;WROAD2+OPS 再把这条链路纳入测试账号受控上线和每日巡检。打卡、人事快照仍按既有表和后续合同扩展。
|
||||
WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化关键历史,再增强镜像查询”。WARCH1 已把请假/补卡最小审批历史留存落到 Prisma schema、回调入库、补偿拉取入库和 QA-only 离线读取 runner;WARCH2 已把已归档历史开放为正式只读 API 和 PC/移动历史页;WROAD2+OPS 再把这条链路纳入测试账号受控上线和每日巡检。WARCH3 已把巡检升级为 systemd timer 和脱敏告警;WCHECK1 已把打卡规范化明细、日/月摘要和规则快照纳入 OA 留存;WEXT1 已把外出/加班样件推进到 dry-run。
|
||||
|
||||
自建留存 V1 基础对象:
|
||||
|
||||
@@ -410,7 +410,7 @@ WADM1 后,企业微信基础设施侧的数据职责调整为“先持久化
|
||||
- OA DB 可保存完整外部键用于补偿和恢复;所有日志、报告、Issue 和文档只输出脱敏前缀。
|
||||
- 不保存 access token、Secret、回调 Token、EncodingAESKey、原始 XML、密文、人脸、完整位置、设备明文或无治理原始 payload。
|
||||
- 企微 API 不可用时,OA 历史页应能查看已入库的历史摘要;V1 不承诺继续办理企微主办流程。
|
||||
- WARCH1 的离线验证命令为 `npm run check:wecom-approval-archive -- --offline --format=json`,只读 OA DB,不调用企微 API;WARCH2 已新增正式只读查询 API 和 PC/移动历史页,沿用只读 OA DB、白名单输出、未映射记录仅 HR/admin/* 可见的边界,不代表完整镜像或应急办理。WROAD2 巡检脚本复用该离线命令,并仅在审批补偿归档步骤以 `WECOM_ARCHIVE_WRITE=1` 写 OA 留存库。
|
||||
- WARCH1 的离线验证命令为 `npm run check:wecom-approval-archive -- --offline --format=json`,只读 OA DB,不调用企微 API;WARCH2 已新增正式只读查询 API 和 PC/移动历史页,沿用只读 OA DB、白名单输出、未映射记录仅 HR/admin/* 可见的边界,不代表完整镜像或应急办理。WROAD2/WARCH3 巡检脚本复用该离线命令,并仅在审批补偿归档步骤以 `WECOM_ARCHIVE_WRITE=1` 写 OA 留存库;WCHECK1 打卡摘要写库必须使用 `WECOM_CHECKIN_ARCHIVE_WRITE=1`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-7
@@ -7,7 +7,7 @@
|
||||
**相关事实源**:[需求 - 统一审批中心规约](./需求-统一审批中心规约.md)、[需求 - 企业微信基础设施规约](./需求-企业微信基础设施规约.md)
|
||||
**体验总依据**:[产品设计规则基线](./产品设计规则.md)
|
||||
|
||||
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WEGOV1 后进一步明确:企微成熟 HR/考勤功能优先上线,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须做自建历史留存、审计、入口、消息和应急兜底。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WAPC2FLIVE 已生成 OA-POC 样件并复跑通过;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已自动发现模板并证明补偿拉取和详情读取可用。WREAD1F 后,用户真机确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;驳回有二次确认,同意单击即通过。WARCH1 已实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和离线读取证明;WARCH2 已提供正式只读历史查询。WROAD2 后当前策略是测试账号受控上线和每日脚本化巡检;外出/加班延期,不作为当前上线阻断;打卡留存 V1 摘要优先。当前仍不代表完整审批镜像、外出/加班、应急接管或恢复回写已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
|
||||
本规约细化请假、补卡、外出、加班与考勤修正的产品要求。WFOC 起,企业微信作为 HR/考勤员工日常审批和打卡规则主操作端;OA 负责按域镜像、应急接管、恢复回写、查询导出和审计。WEGOV1 后进一步明确:企微成熟 HR/考勤功能优先上线,OA 不重造企业微信完整打卡/考勤/HR 产品,但必须做自建历史留存、审计、入口、消息和应急兜底。WAPC2 后,请假和补卡先通过 QA-only runner 只读验证模板详情与低风险 `sp_no` 审批详情;WAPC2FLIVE 已生成 OA-POC 样件并复跑通过;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已自动发现模板并证明补偿拉取和详情读取可用。WREAD1F 后,用户真机确认请假/补卡在企业微信消息卡片、列表和详情页均可看清,远端报告 `/app/artifacts/qa-flow/wecom-approval-readability-gate-20260519T133628Z/summary.json` 为 `PASS_READABLE`;驳回有二次确认,同意单击即通过。WARCH1 已实现请假/补卡最小审批历史留存、回调事件入库、补偿拉取入库和离线读取证明;WARCH2 已提供正式只读历史查询。WROAD2 后进入测试账号受控上线;WARCH3 已新增 07:30 systemd 自动巡检和脱敏企微告警;WCHECK1 已新增打卡规范化明细、日/月摘要和规则快照留存;WEXT1 已支持外出/加班 dry-run,但未 live 创建模板或审批单。当前仍不代表完整审批镜像、外出/加班正式样件、应急接管或恢复回写已实现。PC 和移动页面体验以产品设计规则基线为依据,不重复定义通用审批链和移动导航规则。
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +49,7 @@ REQAUD 复核后,ATTV1 已完成统一入口实现,当前运行时完成度
|
||||
| 请假对团队状态/日历的影响 | `DONE` | 已批准请假可作为团队状态和日历依据。 |
|
||||
| 企业微信打卡作为原始采集 | `DONE` | 由企业微信侧承载定位、人脸、Wi-Fi、设备和防作弊能力;OA 不重造。 |
|
||||
| 企业微信打卡 API 同步 | `PARTIAL` | 已新增同步批次和企微打卡摘要表;有已授权调用打卡 API 的自建应用 Secret 和企微权限时同步,缺配置时记录 `SKIP`。`WECOM_CHECKIN_SECRET` 可作为单独考勤同步应用的显式覆盖;未配置时复用已授权工作台应用的 `WECOM_AGENT_SECRET`。 |
|
||||
| 企微 HR/考勤审批镜像 | `CONTROLLED_OPS_READY` | WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC;WAPC2FLIVE 已完成自动样件与 WAPC2 自动复跑;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已证明请假/补卡补偿拉取和详情读取可用;WREAD1F 已完成请假/补卡模板级真机确认,结论 `PASS_READABLE`。WARCH1 已实现请假/补卡最小历史留存和离线读取,WARCH2 已实现正式只读历史查询。WROAD2+OPS 先做测试账号 3 天每日巡检;后续 `wecom_attendance_approval_mirror_v1` 仍需 WARCH3、WCHECK1、外出/加班 POC 或延期决策和打卡细项。 |
|
||||
| 企微 HR/考勤审批镜像 | `CONTROLLED_OPS_READY` | WAPC2 已新增请假/补卡模板详情和低风险 `sp_no` 审批详情只读 POC;WAPC2FLIVE 已完成自动样件与 WAPC2 自动复跑;WCBK1 已完成真实回调 URL 验证和一条测试审批事件确认;WACP3R 已证明请假/补卡补偿拉取和详情读取可用;WREAD1F 已完成请假/补卡模板级真机确认,结论 `PASS_READABLE`。WARCH1 已实现请假/补卡最小历史留存和离线读取,WARCH2 已实现正式只读历史查询。WROAD2+OPS 做测试账号巡检,WARCH3 已升级自动巡检,WCHECK1 已补打卡摘要留存,WEXT1 只完成外出/加班 dry-run;后续 `wecom_attendance_approval_mirror_v1` 仍需 WROAD2 三天验收、WREAL1 小范围真实试用或明确批准。 |
|
||||
| 员工补卡申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交补卡修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
|
||||
| 外出申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交外出修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
|
||||
| 加班申请审批 | `DONE_AS_OA_FALLBACK` | 当前 OA 能提交加班修正;WFOC 后正常入口迁往企微,OA 仅在应急模式开放并进入恢复回写。 |
|
||||
@@ -231,11 +231,12 @@ WFOC 后,正常审批规则优先以企业微信审批模板和打卡/假期
|
||||
- 已完成 A0.13:`WADM1 自建留存/镜像数据模型设计`,已固定请假/补卡审批留存主表、参与人/动作/字段摘要、回调事件、补偿拉取 run/item、对账 finding、隐私保留、迁移回滚,以及同意单击风险对应的审计和提示策略。
|
||||
- 已完成 A0.14:`WARCH1 最小自建历史留存实现`,已保存请假/补卡企微审批历史、回调事件、补偿拉取记录和对账 finding,并新增离线读取 runner。打卡和人事快照先复用既有表,后续按缺口扩展。
|
||||
- 已完成 A0.15:`WARCH2 正式只读历史查询`,已新增请假/补卡企微审批历史查询 API 和 PC/移动历史页。本阶段只读 OA DB,不调用企微 API;普通员工看本人相关,主管看直属下属和本人相关,HR/admin/* 看全量;页面固定“企业微信主办,OA 自建留存”“仅已归档记录”,不提供审批动作。公网已部署并通过 `/ready`、未登录 401 和远端离线归档复跑。
|
||||
- 当前 A0.16:`WROAD2+OPS 受控上线与每日巡检`,先用测试账号 `SMOKE002 -> SMOKE101 + wangshunli` 做 3 天每日脚本化巡检。巡检只允许补偿归档写 OA 留存库,不写企业微信、不创建模板或审批单、不安装定时任务。
|
||||
- 后续 A0.17:`WARCH3 归档稳定化`,优先稳定每日补偿、归档对账 finding、历史查询可用性、旧 callback gap 解释和报告趋势。
|
||||
- 后续 A0.18:`WCHECK1 打卡摘要留存`,只保存日/月摘要、异常摘要和规则快照;不保存完整位置、人脸、设备明文或原始 payload。
|
||||
- 后续 A0.19:`WEXT1 外出/加班延期决策`,默认延期;仅当真实业务需要或用户批准时,再自动生成低风险 POC 样件。
|
||||
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。正式开发前仍必须完成 WROAD2+OPS、WARCH3、WCHECK1、外出/加班决策和必要打卡细项 POC。
|
||||
- 已完成 A0.16:`WROAD2+OPS 受控上线与每日巡检`,先用测试账号 `SMOKE002 -> SMOKE101 + wangshunli` 做 3 天每日脚本化巡检。巡检只允许补偿归档写 OA 留存库,不写企业微信、不创建模板或审批单。
|
||||
- 已完成 A0.17:`WARCH3 归档稳定化自动巡检`,新增每日补偿、归档对账 finding、历史查询可用性、旧 callback gap 解释、报告趋势、07:30 systemd timer 和脱敏企微告警。
|
||||
- 已完成 A0.18:`WCHECK1 打卡摘要留存`,保存规范化打卡明细、日/月摘要、异常摘要和规则快照;不保存完整位置、人脸、设备明文或原始 payload。
|
||||
- 已完成 A0.19:`WEXT1 外出/加班 dry-run`,支持外出/加班低风险 POC payload 构建;live 创建模板或审批单前必须再次问答批准。
|
||||
- 后续 A0.20:`WREAL1 小范围真实试用`,仅在 WROAD2 三天验收、WARCH3、WCHECK1、WEXT1 dry-run 结果可解释后启动,届时再确认 1-2 名真实员工、试用周期、回滚联系人和验收标准。
|
||||
- 后续 A1:`wecom_attendance_approval_mirror_v1`,同步企微请假、补卡、外出、加班审批、附件、历史和状态回调;该命名承接旧口径 `wecom_native_approval_mirror_v1`。正式开发前仍必须完成 WREAL1 或获得明确批准,并保留打卡细项、假期余额和外出/加班 live 样例边界。
|
||||
- 后续 A2:`oa_hr_emergency_mode_v1`,管理员人工接管、应急提交、应急审批和恢复队列。
|
||||
- 后续 A3:`wecom_recovery_writeback_v1`,企微恢复后 dry-run、自动回写、失败重试和冲突清单。
|
||||
- 后续 A4:团队状态和日历按企微镜像、OA 应急单和回写结果统一解释。
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"WARCH3-0","归档稳定化合同与边界","建立 WARCH3 归档稳定化与自动巡检合同;承接 WROAD2 Day 0 PASS_WITH_FINDINGS,继续测试账号受控上线,不扩大真实员工,不进入 wecom_attendance_approval_mirror_v1。","1) 明确 WARCH3 只稳定每日补偿归档、趋势、finding 去重和脱敏告警;2) 巡检只允许补偿归档写 OA DB,不写企业微信、不创建模板、不提交审批单;3) Secret 来源固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;4) 缺 Secret、权限、可信 IP、可见范围或外部配置时记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;5) live smoke 凭据/pending OCR 子检查和历史 callback gap 为观察项,不阻断企微归档主链路。","受影响流程:WROAD2 Day 0 巡检 -> WARCH3 自动巡检治理 -> 后续 WCHECK1/WEXT1;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv --profile wecom --json。","python3|node","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv","[]","回滚/清理:本行仅合同;如不通过,删除本合同和后续 WARCH3 改动即可,不影响 WROAD2 既有脚本和报告。"
|
||||
"WARCH3-1","每日巡检汇总与 finding 去重","新增 WARCH3 QA/ops 汇总脚本,读取 WROAD2 子报告并生成趋势、主链路状态、观察项、callback gap 解释和去重 finding。","1) 输出 artifacts/qa-flow/warch3-ops-<timestamp>/summary.json;2) 主链路关注企微 runtime、审批补偿归档写 OA、OA 离线归档读取和隐私扫描;3) live smoke 凭据/pending OCR 子检查只作为 observation;4) 重复/漏回调/状态不一致 finding 以脱敏 evidenceDigest 去重;5) 报告不泄露手机号、邮箱、token、secret、完整 userid、完整 template_id、完整 sp_no、原始 XML、密文、表单值、附件、评论或原始 payload。","受影响流程:每日巡检 -> 子报告聚合 -> 管理员看趋势和下一步建议;验证步骤:bash -n scripts/qa/run-warch3-ops-daily.sh;本地执行 WARCH3_SKIP_WROAD2=1 bash scripts/qa/run-warch3-ops-daily.sh 生成脱敏 summary.json;检查报告隐私扫描通过。","bash|node","DONE","DONE","DONE","scripts/qa/run-warch3-ops-daily.sh","[""WARCH3-0""]","回滚/清理:删除新增脚本即可;脚本默认只读,只有复用 WROAD2 时允许进程级 WECOM_ARCHIVE_WRITE=1 写 OA 留存库,不写企业微信。"
|
||||
"WARCH3-2","企微脱敏巡检告警","新增 QA-only 企微巡检告警脚本,巡检失败或 finding 时向管理员 wangshunli/HYD000006 发送脱敏状态和报告路径。","1) 告警固定使用 WECOM_AGENT_SECRET 和企业微信应用消息;2) 仅发送阶段、状态、finding 数、报告路径和下一步建议;3) 默认 dry-run,不发真实消息;4) 真实发送必须带 --send 且 WARCH3_OPS_ALERT_SEND=1;5) 缺收件人绑定、Secret、权限、可见范围或 API 错误记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;6) 不输出手机号、邮箱、token、Secret、完整 userid、完整 template_id、完整 sp_no、表单值或原始 payload。","受影响流程:WARCH3 巡检 -> finding/失败 -> 管理员脱敏企微提醒;验证步骤:cd server && node --check src/scripts/send-wecom-ops-alert.js;cd server && npx vitest run tests/unit/scripts/send-wecom-ops-alert.test.js --reporter=verbose;公网只发送一条脱敏测试告警。","node|vitest","DONE","DONE","DONE","server/src/scripts/send-wecom-ops-alert.js|server/tests/unit/scripts/send-wecom-ops-alert.test.js","[""WARCH3-1""]","回滚/清理:删除告警脚本或不设置 WARCH3_OPS_ALERT_SEND 即可;真实告警为低风险应用消息,保留审计,不写企微业务数据。"
|
||||
"WARCH3-3","systemd 定时器安装脚本","新增服务器 systemd service/timer 安装脚本,每天北京时间 07:30 自动触发 WARCH3/WROAD2 巡检。","1) 新增 deploy/trialrun/install-warch3-ops-timer.sh;2) service 只执行仓库脚本,不写企业微信;3) timer 使用 Asia/Shanghai 语义的 07:30;4) 安装、启用、手动触发和卸载路径明确;5) 不修改公开业务 API、Prisma schema、前端页面或 feature flag。","受影响流程:受控上线巡检 -> 服务器定时触发 -> 每日脱敏报告;验证步骤:bash -n deploy/trialrun/install-warch3-ops-timer.sh;远端 SYNC_ENV=0 部署后执行安装脚本;systemctl list-timers hyb-oa-warch3-ops.timer;systemctl start hyb-oa-warch3-ops.service。","bash|systemctl|ssh","DONE","DONE","DONE","deploy/trialrun/install-warch3-ops-timer.sh","[""WARCH3-1""]","回滚/清理:运行安装脚本 --uninstall 或 systemctl disable --now hyb-oa-warch3-ops.timer 并删除 service/timer 文件;不影响应用容器和数据库。;实际结果:安装脚本已兼容 docker compose v2 与 docker-compose v1;远端 timer 已启用,下次触发 2026-05-21 07:30 CST。"
|
||||
"WARCH3-4","本地门禁与单测","执行 WARCH3 本地合同、脚本、单测、文档和最小门禁。","1) CSV 校验通过;2) process gate baseline 和 WARCH3 issue profile 通过;3) bash/node 语法通过;4) focused vitest 通过;5) 文档审计、skill 校验、git diff --check、scripts/check-minimal.sh 通过。","受影响流程:WARCH3 实施候选 -> 本地 QA -> 可部署候选;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv --profile wecom --json;bash -n scripts/qa/run-warch3-ops-daily.sh;bash -n deploy/trialrun/install-warch3-ops-timer.sh;cd server && node --check src/scripts/send-wecom-ops-alert.js;cd server && npx vitest run tests/unit/scripts/send-wecom-ops-alert.test.js --reporter=verbose;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;git diff --check;bash scripts/check-minimal.sh。","python3|node|bash|vitest|git","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv|scripts/qa/run-warch3-ops-daily.sh|deploy/trialrun/install-warch3-ops-timer.sh|server/src/scripts/send-wecom-ops-alert.js|server/tests/unit/scripts/send-wecom-ops-alert.test.js|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH3-2"",""WARCH3-3""]","回滚/清理:门禁失败不部署、不安装 timer;修复后重跑。"
|
||||
"WARCH3-5","公网部署与定时器验证","本地门禁通过后,以 SYNC_ENV=0 受控部署,安装 systemd timer,手动触发一次 service,并发送一条脱敏告警测试消息。","1) 使用 SYNC_ENV=0,不同步本地 env;2) /ready 正常;3) 远端手动触发 WARCH3 service 生成 summary.json;4) systemd timer 已启用且下次运行时间为北京时间 07:30;5) 告警仅发送脱敏状态和报告路径;6) 远端报告和告警不泄露完整外部键或敏感值。","受影响流程:部署 -> /ready -> systemd timer -> 手动 service -> 脱敏企微告警;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;ssh 远端执行 deploy/trialrun/install-warch3-ops-timer.sh;systemctl start hyb-oa-warch3-ops.service;WARCH3_OPS_ALERT_SEND=1 npm run send:wecom-ops-alert 或等效命令。","bash|ssh|curl|systemctl|npm","DONE","DONE","DONE","deploy/trialrun/deploy.sh|deploy/trialrun/install-warch3-ops-timer.sh|scripts/qa/run-warch3-ops-daily.sh","[""WARCH3-4""]","回滚/清理:如 systemd 或告警异常,先 disable timer 并保留报告;不扩大真实用户,不写企业微信业务数据。;实际结果:SYNC_ENV=0 部署通过,/ready 正常;远端 service 手动触发成功,报告 /app/artifacts/qa-flow/warch3-ops-20260520-054841/summary.json 为 PASS_WITH_FINDINGS;告警报告 /app/artifacts/qa-flow/wecom-ops-alert-20260520T054851Z/summary.json 为 PASS_ALERT_SENT_WITH_CONFIG_GAPS,已向有效管理员 HYD000006 发送脱敏企微消息,wangshunli 仅记录为非员工号别名 gap。"
|
||||
"WARCH3-6","事实源回写","更新路线图、API 目录、管理员手册、试运行报告、文档索引、事实源矩阵和项目 skill,固化 WARCH3 结果。","1) 文档明确 WARCH3 已将每日巡检升级为 systemd timer;2) 告警为脱敏企微应用消息;3) live smoke/OCR finding 属观察项;4) WARCH3 后仍需 WCHECK1 和 WEXT1 dry-run,再规划真实小范围试用;5) 不暗示完整镜像、应急或回写已完成。","受影响流程:事实源 -> 后续 WCHECK1/WEXT1/WREAL1 规划;验证步骤: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 ""WARCH3|systemd|07:30|脱敏告警|WCHECK1|WEXT1|WREAL1|BLOCKED_BY_EXTERNAL_CONFIG"" docs .agents/skills/hyb-oa-delivery/SKILL.md。","node|python3|rg","DONE","DONE","DONE","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-企业微信基础设施规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WARCH3-5""]","回滚/清理:恢复本阶段文档改动即可;不修改历史 Issue 快照。"
|
||||
"WARCH3-7","状态回填与中文提交","完成 WARCH3 状态回填、本地与公网证据登记和中文提交。","1) Test_Method 全部真实通过或真实 SKIP;2) Issue 状态回填;3) 远端报告路径、timer 状态和告警结果写入 Notes;4) git diff --check 通过;5) 中文提交完成。","受影响流程:WARCH3 实施 -> 证据回写 -> 可进入 WCHECK1/WEXT1;验证步骤:git status --short;git diff --check;git log -1 --oneline;确认提交信息包含 WARCH3。","git|manual","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-archive-stabilization-ops-v1.csv|docs/*|scripts/qa/*|deploy/trialrun/*|server/src/scripts/send-wecom-ops-alert.js|server/tests/unit/scripts/send-wecom-ops-alert.test.js","[""WARCH3-6""]","提交建议:WARCH3 增加归档稳定化自动巡检。若公网因外部配置 SKIP,仍提交真实阻断事实,不伪造成 PASS。;实际结果:WARCH3 本地门禁、公网部署、timer、service、脱敏告警和事实源回写已完成;live smoke 凭据/pending OCR 与历史 callback gap 继续作为观察项。;回滚/清理:如后续公网定时巡检异常,可运行 deploy/trialrun/install-warch3-ops-timer.sh uninstall 停用 timer;报告和测试告警保留审计,不清理业务数据。"
|
||||
|
@@ -0,0 +1,9 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"WCHECK1-0","打卡摘要留存合同与边界","建立 WCHECK1 打卡摘要留存合同;承接 WROAD2/WARCH3,目标是 OA 自建留存规范化打卡明细、日/月摘要和规则快照,企微不可用时仍能查看已保存历史摘要。","1) 保存范围为规范化打卡记录、日摘要、月摘要、异常摘要、规则快照摘要和原始记录 hash;2) 禁止保存完整位置、人脸、设备明文、原始 payload、token、Secret;3) Secret 来源固定 WECOM_AGENT_SECRET 或显式 WECOM_CHECKIN_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;4) 缺 Secret、权限、可信 IP、可见范围或企微 API 错误记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;5) 不写企业微信、不创建审批模板或审批单、不扩大真实员工。","受影响流程:企微打卡只读拉取 -> OA 规范化留存 -> 离线摘要读取;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv --profile wecom --json。","python3|node","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv","[]","回滚/清理:本行仅合同;如不通过,删除本合同和后续 WCHECK1 改动即可,不影响既有 WecomCheckinRecord。"
|
||||
"WCHECK1-1","打卡摘要 Schema 与迁移","新增打卡日/月摘要和规则快照表,复用 WecomCheckinRecord 作为规范化明细。","1) 新增 WecomCheckinDailySummary、WecomCheckinMonthlySummary、WecomCheckinRuleSnapshot;2) 表内只保存摘要、计数、首末时间、异常计数、规则摘要、hash 和外部 userid 前缀/必要映射,不保存位置、人脸、设备明文或原始 payload;3) 有唯一键保证按员工/日期、员工/月、外部 userid/规则日幂等;4) 迁移可回滚说明明确。","受影响流程:已同步打卡明细 -> 摘要表 -> 离线读取;验证步骤:cd server && npm run prisma:validate;检查 server/prisma/migrations/*_wecom_checkin_summary_archive_v1/migration.sql;必要时受控部署执行 prisma migrate deploy。","prisma|sql","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*_wecom_checkin_summary_archive_v1/migration.sql","[""WCHECK1-0""]","回滚/清理:如未部署,恢复 schema 和删除迁移目录;如已部署,需单独审批后 DROP wecom_checkin_daily_summaries、wecom_checkin_monthly_summaries、wecom_checkin_rule_snapshots。"
|
||||
"WCHECK1-2","打卡摘要归档服务","新增服务函数,从 WecomCheckinRecord 生成日/月摘要,并可在 live 只读模式拉取打卡规则形成规则快照。","1) 摘要生成幂等 upsert;2) 日摘要包含打卡次数、首末打卡时间、异常计数、结果摘要、source 摘要、raw_hash 聚合;3) 月摘要包含天数、异常天数、缺卡候选天数、首末覆盖日期;4) 规则快照只存规则摘要、hash、员工映射和时间窗口,不存原始 payload;5) 企微 API 阻断按 SKIP/BLOCKED_BY_EXTERNAL_CONFIG 归类。","受影响流程:打卡记录 -> 摘要生成 -> 规则快照 -> 后续离线查询;验证步骤:cd server && node --check src/services/integrations/wecomCheckinArchive.js;cd server && npx vitest run tests/unit/services/wecomCheckinArchive.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/services/integrations/wecomCheckinArchive.js|server/tests/unit/services/wecomCheckinArchive.test.js","[""WCHECK1-1""]","回滚/清理:删除新增服务和测试即可;服务不写企业微信,写 OA 留存表用于审计。"
|
||||
"WCHECK1-3","打卡摘要 QA runner","新增 QA-only runner check-wecom-checkin-summary-archive,支持 live 只读拉取、persist 写 OA 摘要和 offline 离线读取证明。","1) 默认 offline/read-only,不调用企微;2) live 模式仅调用打卡读取和规则读取,不写企业微信;3) 写 OA 摘要必须同时带 --persist-to-db 和 WECOM_CHECKIN_ARCHIVE_WRITE=1;4) 输出 artifacts/qa-flow/wecom-checkin-summary-archive-<timestamp>/summary.json;5) 报告只输出计数、日期、状态、异常摘要和脱敏 userid 前缀,不泄露完整 userid、位置、人脸、设备、token、Secret 或原始 payload。","受影响流程:企微打卡只读 -> OA 摘要留存 -> 企微 API 不可用时离线读取;验证步骤:cd server && node --check src/scripts/check-wecom-checkin-summary-archive.js;cd server && npx vitest run tests/unit/scripts/check-wecom-checkin-summary-archive.test.js tests/unit/services/wecomCheckinArchive.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-checkin-summary-archive.js|server/tests/unit/scripts/check-wecom-checkin-summary-archive.test.js|server/package.json","[""WCHECK1-2""]","回滚/清理:删除 runner 和 package 脚本即可;若已写 OA 摘要,保留审计或按后续人工批准清理。"
|
||||
"WCHECK1-4","本地门禁与隐私测试","执行 WCHECK1 本地合同、Prisma、语法、focused 单测、文档和最小门禁。","1) CSV 与 process gate 通过;2) Prisma validate 通过;3) node --check 通过;4) 单测覆盖空记录、幂等、规则快照脱敏、外部配置阻断、隐私不泄露;5) git diff --check 和 scripts/check-minimal.sh 通过。","受影响流程:WCHECK1 实施候选 -> 本地 QA -> 可部署候选;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv --profile wecom --json;cd server && npm run prisma:validate;cd server && node --check src/services/integrations/wecomCheckinArchive.js;cd server && node --check src/scripts/check-wecom-checkin-summary-archive.js;cd server && npx vitest run tests/unit/services/wecomCheckinArchive.test.js tests/unit/scripts/check-wecom-checkin-summary-archive.test.js --reporter=verbose;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;git diff --check;bash scripts/check-minimal.sh。","python3|node|prisma|vitest|bash|git","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv|server/prisma/*|server/src/services/integrations/wecomCheckinArchive.js|server/src/scripts/check-wecom-checkin-summary-archive.js|server/tests/unit/*|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WCHECK1-3""]","回滚/清理:本地门禁失败不部署;修复后重跑。"
|
||||
"WCHECK1-5","公网只读与归档验证","本地门禁通过后,以 SYNC_ENV=0 部署并远端执行 WCHECK1 runner,证明可写 OA 摘要并离线读取。","1) 使用 SYNC_ENV=0,不同步本地 env;2) /ready 正常;3) 远端 live 只读拉取打卡短窗口并在双开关下写 OA 摘要;4) 离线读取不调用企微 API、不读取企微 Secret;5) 若权限、可信 IP、可见范围或 Secret 阻断,记录真实 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;6) 报告隐私扫描通过。","受影响流程:部署 -> 企微打卡只读 -> OA 摘要归档 -> 离线读取;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;远端执行 WECOM_CHECKIN_ARCHIVE_WRITE=1 npm run check:wecom-checkin-summary-archive -- --live --persist-to-db --format=json;远端执行 npm run check:wecom-checkin-summary-archive -- --offline --format=json。","bash|ssh|curl|npm","DONE","DONE","DONE","deploy/trialrun/deploy.sh|server/src/scripts/check-wecom-checkin-summary-archive.js","[""WCHECK1-4""]","回滚/清理:部署异常按既有 trialrun 回滚;已写 OA 摘要保留审计,不写企业微信数据。;实际结果:SYNC_ENV=0 部署通过,/ready 正常;live 报告 /app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054907Z/summary.json 为 PASS_CHECKIN_ARCHIVE_READY,本窗口 0 条打卡明细、2 条规则快照写入 OA 留存;offline 报告 /app/artifacts/qa-flow/wecom-checkin-summary-archive-20260520T054926Z/summary.json 为 PASS_CHECKIN_ARCHIVE_OFFLINE_READY。"
|
||||
"WCHECK1-6","事实源回写","更新路线图、API 目录、请假考勤规约、企业微信基础设施规约、管理员手册、试运行报告、文档索引、事实源矩阵和项目 skill。","1) 文档明确 WCHECK1 保存规范化明细、日/月摘要和规则快照;2) 明确不保存位置、人脸、设备明文或原始 payload;3) 明确企微不可用时 OA 只能查看已保存历史摘要,不代表继续办理;4) 后续仍需 WEXT1 dry-run 和 WREAL1 小范围真实试用。","受影响流程:事实源 -> 后续 WEXT1/WREAL1/镜像规划;验证步骤: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 ""WCHECK1|打卡摘要|规则快照|不保存完整位置|WEXT1|WREAL1|wecom_attendance_approval_mirror_v1"" docs .agents/skills/hyb-oa-delivery/SKILL.md。","node|python3|rg","DONE","DONE","DONE","docs/企微优先开发路线图.md|docs/企微API能力目录.md|docs/需求-请假考勤修正规约.md|docs/需求-企业微信基础设施规约.md|docs/管理员手册.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md|.agents/skills/hyb-oa-delivery/SKILL.md","[""WCHECK1-5""]","回滚/清理:恢复本阶段文档改动即可。"
|
||||
"WCHECK1-7","状态回填与中文提交","完成 WCHECK1 状态回填、本地与公网证据登记和中文提交。","1) Test_Method 全部真实通过或真实 SKIP;2) Issue 状态回填;3) 公网报告路径写入 Notes;4) git diff --check 通过;5) 中文提交完成。","受影响流程:WCHECK1 实施 -> 证据回写 -> 可进入 WEXT1/WREAL1;验证步骤:git status --short;git diff --check;git log -1 --oneline;确认提交信息包含 WCHECK1。","git|manual","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-checkin-summary-archive-v1.csv|server/*|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WCHECK1-6""]","提交建议:WCHECK1 增加企微打卡摘要留存。若公网因外部配置 SKIP,仍提交真实阻断事实,不伪造成 PASS。;实际结果:WCHECK1 本地门禁、公网 live 写 OA 留存、离线读取和事实源回写已完成;未写企业微信,未保存位置、人脸、设备明文或原始 payload。"
|
||||
|
@@ -0,0 +1,7 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"WEXT1-0","外出加班 dry-run 合同与边界","建立 WEXT1 外出/加班低风险 POC dry-run 合同;承接 WROAD2/WARCH3/WCHECK1,当前只证明 payload 可构建、非自审和脱敏报告,不 live 创建模板或审批单。","1) 范围仅 outing 外出、overtime 加班;2) 默认 dry-run,不写企业微信、不创建模板、不提交审批单;3) 申请人/审批人固定 SMOKE002 -> SMOKE101,禁止自审;4) Secret 来源固定 WECOM_AGENT_SECRET,不尝试其他 Secret、无 fallback、不允许运行时猜 Secret;5) 缺用户、绑定、权限、可信 IP、可见范围或配置时记录 NEEDS_CONFIG 或 SKIP/BLOCKED_BY_EXTERNAL_CONFIG;6) live 创建必须另起问答和新合同。","受影响流程:外出/加班 POC 准备 -> dry-run payload -> 后续是否 live 创建由人工决定;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv --profile wecom --json。","python3|node","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv","[]","回滚/清理:本行仅合同;dry-run 不创建企微记录,无需企微清理。"
|
||||
"WEXT1-1","扩展审批样件 runner 目标","扩展现有 check-wecom-approval-fixtures runner,支持 --target=outing、--target=overtime 或 --targets=outing,overtime 的 dry-run payload 构建。","1) 默认仍为 leave/missed_punch,避免破坏 WAPC2F/WAPC2FLIVE;2) WEXT1 使用 --target=outing --target=overtime,且不传 --live、--create-templates 或 --submit-test-approvals;3) 外出/加班模板使用低风险 Text/Date/Textarea 等最小通用控件;4) live 写入路径对 outing/overtime 继续受 --live --create-templates --submit-test-approvals + WECOM_APPROVAL_FIXTURE_WRITE=1 双开关保护,但本轮不执行 live。","受影响流程:样件 runner -> 外出/加班 payload dry-run -> 后续 live 问答;验证步骤:cd server && node --check src/scripts/check-wecom-approval-fixtures.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-fixtures.test.js --reporter=verbose。","node|vitest","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-fixtures.js|server/tests/unit/scripts/check-wecom-approval-fixtures.test.js","[""WEXT1-0""]","回滚/清理:恢复 runner 和测试即可;本轮不写企微,无企微记录清理。"
|
||||
"WEXT1-2","外出加班 dry-run 本地验证","执行 WEXT1 本地 dry-run,确认非自审、Secret 固定、payload 摘要和脱敏报告。","1) actor precheck 通过或真实 NEEDS_CONFIG;2) payload summary 包含 outing/overtime 且不含完整 userid、template_id、sp_no、表单值或原始 payload;3) live 缺双开关时不写;4) 不调用企微写接口;5) 结论为 PASS_FIXTURE_DRY_RUN_READY 或真实 PARTIAL/SKIP。","受影响流程:SMOKE002 -> SMOKE101 预检 -> 外出/加班 payload -> dry-run 报告;验证步骤:cd server && npm run check:wecom-approval-fixtures -- --format=json --target=outing --target=overtime;检查 artifacts/qa-flow/wecom-approval-fixtures-*/summary.json。","npm|node","DONE","DONE","DONE","server/src/scripts/check-wecom-approval-fixtures.js","[""WEXT1-1""]","回滚/清理:dry-run 只写本地 artifacts,保留审计或后续清理;不写企微。"
|
||||
"WEXT1-3","本地门禁与文档回写","执行 WEXT1 合同、流程、runner 单测、文档和最小门禁,并更新路线事实源。","1) CSV 与 process gate 通过;2) runner 语法和 focused vitest 通过;3) 文档明确外出/加班只完成 dry-run,live 创建前必须再次问答批准;4) git diff --check 和 scripts/check-minimal.sh 通过。","受影响流程:WEXT1 dry-run 实施候选 -> 本地 QA -> 后续 live 决策;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv;node scripts/qa/check-process-contract.mjs --baseline --json;node scripts/qa/check-process-contract.mjs --issue issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv --profile wecom --json;cd server && node --check src/scripts/check-wecom-approval-fixtures.js;cd server && npx vitest run tests/unit/scripts/check-wecom-approval-fixtures.test.js --reporter=verbose;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;git diff --check;bash scripts/check-minimal.sh。","python3|node|vitest|bash|git","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv|server/src/scripts/check-wecom-approval-fixtures.js|server/tests/unit/scripts/check-wecom-approval-fixtures.test.js|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WEXT1-2""]","回滚/清理:本地门禁失败不部署;修复后重跑。"
|
||||
"WEXT1-4","公网 dry-run 验证","以 SYNC_ENV=0 部署后,在远端执行外出/加班 dry-run,不设置 WECOM_APPROVAL_FIXTURE_WRITE=1。","1) /ready 正常;2) 远端 dry-run 结论 PASS_FIXTURE_DRY_RUN_READY 或真实 PARTIAL/SKIP;3) 不创建模板、不提交审批单、不写企业微信;4) 报告不泄露完整外部键或敏感值;5) live 创建留到下一轮问答。","受影响流程:部署 -> 远端 WEXT1 dry-run -> 后续人工决定是否 live;验证步骤:SYNC_ENV=0 bash deploy/trialrun/deploy.sh;curl -fsS https://oa.hyinda.com/ready;远端执行 npm run check:wecom-approval-fixtures -- --format=json --target=outing --target=overtime。","bash|ssh|curl|npm","DONE","DONE","DONE","deploy/trialrun/deploy.sh|server/src/scripts/check-wecom-approval-fixtures.js","[""WEXT1-3""]","回滚/清理:远端 dry-run 只生成报告,不写企微;如部署异常按既有 trialrun 回滚。;实际结果:SYNC_ENV=0 部署通过,/ready 正常;远端 dry-run 报告 /app/artifacts/qa-flow/wecom-approval-fixtures-20260520T054940Z/summary.json 为 PASS_FIXTURE_DRY_RUN_READY;未设置 WECOM_APPROVAL_FIXTURE_WRITE,未创建模板或审批单。"
|
||||
"WEXT1-5","状态回填与中文提交","完成 WEXT1 状态回填、本地与公网证据登记和中文提交。","1) Test_Method 全部真实通过或真实 SKIP;2) Issue 状态回填;3) 公网 dry-run 报告路径写入 Notes;4) git diff --check 通过;5) 中文提交完成;6) 最终用问答方式询问是否批准单次 live 创建 OA-POC 外出/加班测试样件。","受影响流程:WEXT1 dry-run -> 证据回写 -> 人工 live 决策;验证步骤:git status --short;git diff --check;git log -1 --oneline;确认提交信息包含 WEXT1。","git|manual","DONE","DONE","DONE","issues/2026-05-20_12-48-04-wecom-outing-overtime-fixture-dryrun-v1.csv|server/src/scripts/check-wecom-approval-fixtures.js|server/tests/unit/scripts/check-wecom-approval-fixtures.test.js|docs/*|.agents/skills/hyb-oa-delivery/SKILL.md","[""WEXT1-4""]","提交建议:WEXT1 增加外出加班样件 dry-run。后续 live 创建必须另起 Issue CSV,并由用户明确批准。;实际结果:WEXT1 本地门禁、公网 dry-run 和事实源回写已完成;live 创建外出/加班样件仍需下一轮问答和独立 Issue CSV。"
|
||||
|
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
set -u -o pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
|
||||
REPORT_DIR="${WARCH3_OPS_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/warch3-ops-$(date +%Y%m%d-%H%M%S)}"
|
||||
WROAD2_REPORT_DIR="${WARCH3_WROAD2_REPORT_DIR:-$REPORT_DIR/wroad2-ops}"
|
||||
SUMMARY_TXT="$REPORT_DIR/summary.txt"
|
||||
STATUS=0
|
||||
|
||||
mkdir -p "$REPORT_DIR"
|
||||
: >"$SUMMARY_TXT"
|
||||
|
||||
run_wroad2() {
|
||||
if [[ "${WARCH3_SKIP_WROAD2:-0}" == "1" ]]; then
|
||||
echo "[warch3-ops] skip WROAD2 execution by WARCH3_SKIP_WROAD2=1" | tee -a "$SUMMARY_TXT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo ">>> [warch3-ops] run WROAD2 controlled ops" | tee -a "$SUMMARY_TXT"
|
||||
if WROAD2_OPS_REPORT_DIR="$WROAD2_REPORT_DIR" bash "$ROOT_DIR/scripts/qa/run-wroad2-ops-daily.sh" \
|
||||
> >(tee "$REPORT_DIR/wroad2.log") 2>&1; then
|
||||
echo "[warch3-ops][PASS] wroad2" | tee -a "$SUMMARY_TXT"
|
||||
else
|
||||
local code="$?"
|
||||
echo "[warch3-ops][FAIL] wroad2 exit=$code" | tee -a "$SUMMARY_TXT"
|
||||
STATUS=1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_wroad2_summary() {
|
||||
if [[ -n "${WARCH3_WROAD2_SUMMARY_FILE:-}" && -f "${WARCH3_WROAD2_SUMMARY_FILE}" ]]; then
|
||||
printf '%s\n' "$WARCH3_WROAD2_SUMMARY_FILE"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$WROAD2_REPORT_DIR/summary.json" ]]; then
|
||||
printf '%s\n' "$WROAD2_REPORT_DIR/summary.json"
|
||||
return 0
|
||||
fi
|
||||
find "$ROOT_DIR/artifacts/qa-flow" -maxdepth 2 -path '*/wroad2-ops-*/summary.json' -type f 2>/dev/null \
|
||||
| sort \
|
||||
| tail -n 1
|
||||
}
|
||||
|
||||
write_summary_json() {
|
||||
local wroad2_summary
|
||||
wroad2_summary="$(resolve_wroad2_summary)"
|
||||
|
||||
node - "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" "$wroad2_summary" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const [reportDir, baseURL, statusInput, wroad2SummaryFile] = process.argv.slice(2);
|
||||
|
||||
function readJson(file) {
|
||||
if (!file || !fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value) {
|
||||
return crypto.createHash("sha256").update(String(value ?? "")).digest("hex");
|
||||
}
|
||||
|
||||
function safeString(value, maxLength = 240) {
|
||||
return String(value ?? "")
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function reportStatus(report) {
|
||||
return report?.status || report?.conclusion || "UNKNOWN";
|
||||
}
|
||||
|
||||
function addFinding(findings, source, status, detail, severity = "info") {
|
||||
const text = `${source}:${status}:${detail}`;
|
||||
const evidenceDigest = hash(text).slice(0, 16);
|
||||
if (findings.some((item) => item.evidenceDigest === evidenceDigest)) return;
|
||||
findings.push({
|
||||
source,
|
||||
status,
|
||||
severity,
|
||||
evidenceDigest,
|
||||
detail: safeString(detail),
|
||||
});
|
||||
}
|
||||
|
||||
let commit = "unknown";
|
||||
try {
|
||||
commit = execSync("git rev-parse --short HEAD", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
||||
} catch {}
|
||||
|
||||
const wroad2 = readJson(wroad2SummaryFile);
|
||||
const childReports = wroad2?.childReports && typeof wroad2.childReports === "object" ? wroad2.childReports : {};
|
||||
const findings = [];
|
||||
const observations = [];
|
||||
const requiredChildren = ["wecomRuntimeReady", "approvalCompensation", "approvalArchive"];
|
||||
for (const key of requiredChildren) {
|
||||
const status = reportStatus(childReports[key]);
|
||||
const passLikeStatuses = new Set([
|
||||
"PASS",
|
||||
"PASS_RUNTIME_READY",
|
||||
"PASS_WITH_CONFIG_GAPS",
|
||||
"PASS_WITH_FINDINGS",
|
||||
"PASS_COMPENSATION_POC",
|
||||
"PASS_ARCHIVE_HISTORY_READABLE",
|
||||
]);
|
||||
if (!childReports[key]) {
|
||||
addFinding(findings, key, "MISSING", "required WROAD2 child report missing", "warn");
|
||||
} else if (!passLikeStatuses.has(status)) {
|
||||
addFinding(findings, key, status, "required WROAD2 child report is not pass-like", "warn");
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(childReports)) {
|
||||
const status = reportStatus(value);
|
||||
if (key === "liveReadiness" && status !== "PASS") {
|
||||
observations.push({
|
||||
source: key,
|
||||
status,
|
||||
reason: "live smoke credentials or pending OCR subcheck is tracked as WARCH3 observation, not archive blocker",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (status === "PASS_WITH_FINDINGS") {
|
||||
addFinding(findings, key, status, "child report has explainable findings", "info");
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(wroad2?.findingCount || 0) > 0) {
|
||||
observations.push({
|
||||
source: "wroad2",
|
||||
status: reportStatus(wroad2),
|
||||
findingCount: Number(wroad2.findingCount || 0),
|
||||
reason: "WROAD2 finding count kept for trend; known live smoke/OCR and historical callback gap do not block archive ops",
|
||||
});
|
||||
}
|
||||
|
||||
if (!wroad2) {
|
||||
addFinding(findings, "wroad2", "MISSING", "WROAD2 summary not found", "error");
|
||||
}
|
||||
|
||||
const shellFailed = Number(statusInput || 0) !== 0;
|
||||
let status = "PASS_ARCHIVE_OPS_READY";
|
||||
if (shellFailed || reportStatus(wroad2) === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG" || findings.some((item) => item.severity === "error")) {
|
||||
status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
} else if (findings.length || observations.length || reportStatus(wroad2) === "PASS_WITH_FINDINGS") {
|
||||
status = "PASS_WITH_FINDINGS";
|
||||
}
|
||||
|
||||
const summary = {
|
||||
issueId: "WARCH3",
|
||||
mode: "wecom-archive-stabilization-ops",
|
||||
status,
|
||||
conclusion: status,
|
||||
baseURL,
|
||||
commit,
|
||||
checkedAt: new Date().toISOString(),
|
||||
reportDir: path.resolve(reportDir),
|
||||
sourceReports: {
|
||||
wroad2: wroad2SummaryFile ? path.resolve(wroad2SummaryFile) : null,
|
||||
},
|
||||
mainChain: {
|
||||
wecomRuntimeReady: reportStatus(childReports.wecomRuntimeReady),
|
||||
approvalCompensation: reportStatus(childReports.approvalCompensation),
|
||||
approvalArchive: reportStatus(childReports.approvalArchive),
|
||||
},
|
||||
findingCount: findings.length,
|
||||
findings,
|
||||
observations,
|
||||
schedule: {
|
||||
timer: "hyb-oa-warch3-ops.timer",
|
||||
plannedLocalTime: "07:30 Asia/Shanghai",
|
||||
currentRun: process.env.WARCH3_SKIP_WROAD2 === "1" ? "summary_only" : "wroad2_then_summary",
|
||||
},
|
||||
alertPolicy: {
|
||||
enabledWhen: "finding_or_failure",
|
||||
recipient: "wangshunli/HYD000006",
|
||||
messageBoundary: "stage/status/finding count/report path/next step only",
|
||||
},
|
||||
boundaries: {
|
||||
noWeComWrites: true,
|
||||
noFixtureCreation: true,
|
||||
archivePersistence: "OA_DB_only_via_WROAD2_compensation_step",
|
||||
liveSmokeAndOcr: "observation_not_archive_blocker",
|
||||
},
|
||||
privacy: "masked counts/status/report paths only; no phone/email/token/secret/full userid/full template_id/full sp_no/raw XML/ciphertext/form values/attachments/comments/raw payload",
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
console.log(`[warch3-ops] summary=${path.join(reportDir, "summary.json")}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
run_privacy_smoke() {
|
||||
if grep -RInE 'access_token=[A-Za-z0-9._-]{8,}|1[3-9][0-9]{9}|[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,}|-----BEGIN|BEGIN [A-Z ]*KEY' "$REPORT_DIR"; then
|
||||
echo "[warch3-ops][FAIL] privacy smoke matched sensitive pattern" | tee -a "$SUMMARY_TXT"
|
||||
STATUS=1
|
||||
fi
|
||||
}
|
||||
|
||||
maybe_send_alert() {
|
||||
local summary_file="$REPORT_DIR/summary.json"
|
||||
if [[ "${WARCH3_SEND_ALERT:-0}" != "1" || ! -f "$summary_file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local should_alert
|
||||
should_alert="$(node -e "const s=require(process.argv[1]); console.log((s.status!=='PASS_ARCHIVE_OPS_READY'||Number(s.findingCount||0)>0)?'1':'0')" "$summary_file" 2>/dev/null || printf '0')"
|
||||
if [[ "$should_alert" != "1" ]]; then
|
||||
echo "[warch3-ops] alert skipped: no finding or failure" | tee -a "$SUMMARY_TXT"
|
||||
return 0
|
||||
fi
|
||||
echo ">>> [warch3-ops] send dehydrated WeCom alert" | tee -a "$SUMMARY_TXT"
|
||||
if (cd "$SERVER_DIR" && npm run send:wecom-ops-alert -- --summary-file="$summary_file" --recipient-employee-no=wangshunli --recipient-employee-no=HYD000006 --send --format=json) \
|
||||
> >(tee "$REPORT_DIR/wecom-alert.log") 2>&1; then
|
||||
echo "[warch3-ops][PASS] alert" | tee -a "$SUMMARY_TXT"
|
||||
else
|
||||
local code="$?"
|
||||
echo "[warch3-ops][FINDING] alert exit=$code" | tee -a "$SUMMARY_TXT"
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
write_summary_json || STATUS=1
|
||||
run_privacy_smoke
|
||||
maybe_send_alert
|
||||
run_privacy_smoke
|
||||
echo "[warch3-ops] report=$REPORT_DIR" | tee -a "$SUMMARY_TXT"
|
||||
exit "$STATUS"
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
|
||||
run_wroad2
|
||||
@@ -35,7 +35,9 @@
|
||||
"check:wecom-approval-archive": "node src/scripts/check-wecom-approval-archive.js",
|
||||
"check:wecom-approval-readability-gate": "node src/scripts/check-wecom-approval-readability-gate.js",
|
||||
"check:wecom-approval-poc": "node src/scripts/check-wecom-approval-poc.js",
|
||||
"check:wecom-checkin-summary-archive": "node src/scripts/check-wecom-checkin-summary-archive.js",
|
||||
"check:wecom-runtime-ready": "node src/scripts/check-wecom-runtime-ready.js",
|
||||
"send:wecom-ops-alert": "node src/scripts/send-wecom-ops-alert.js",
|
||||
"resolve:wecom-oa-users": "node src/scripts/resolve-wecom-oa-user-consistency.js",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- WCHECK1: 企微打卡摘要留存。
|
||||
-- 仅保存规范化摘要、规则快照和 hash;不保存位置、人脸、设备明文或原始 payload。
|
||||
|
||||
CREATE TABLE "wecom_checkin_daily_summaries" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"employee_id" UUID NOT NULL,
|
||||
"external_user_id_prefix" VARCHAR(32),
|
||||
"checkin_date" DATE NOT NULL,
|
||||
"record_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"first_checkin_at" TIMESTAMP(3),
|
||||
"last_checkin_at" TIMESTAMP(3),
|
||||
"exception_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"abnormal_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"checkin_type_summary" JSONB,
|
||||
"result_summary" JSONB,
|
||||
"source_type_summary" JSONB,
|
||||
"raw_hash_digest" VARCHAR(64),
|
||||
"source_record_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"last_sync_run_id" UUID,
|
||||
"archived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "wecom_checkin_daily_summaries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "wecom_checkin_monthly_summaries" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"employee_id" UUID NOT NULL,
|
||||
"month" VARCHAR(7) NOT NULL,
|
||||
"start_date" DATE NOT NULL,
|
||||
"end_date" DATE NOT NULL,
|
||||
"summary_day_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"checkin_day_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"exception_day_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"abnormal_record_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"missing_day_candidate_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"first_checkin_at" TIMESTAMP(3),
|
||||
"last_checkin_at" TIMESTAMP(3),
|
||||
"result_summary" JSONB,
|
||||
"raw_hash_digest" VARCHAR(64),
|
||||
"archived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "wecom_checkin_monthly_summaries_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "wecom_checkin_rule_snapshots" (
|
||||
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
"employee_id" UUID,
|
||||
"external_user_id_prefix" VARCHAR(32),
|
||||
"snapshot_date" DATE NOT NULL,
|
||||
"rule_hash" VARCHAR(64) NOT NULL,
|
||||
"rule_summary" JSONB,
|
||||
"source_endpoint" VARCHAR(120) NOT NULL DEFAULT '/cgi-bin/checkin/getcheckinoption',
|
||||
"archived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "wecom_checkin_rule_snapshots_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "wecom_checkin_daily_summaries_emp_date_ux" ON "wecom_checkin_daily_summaries"("employee_id", "checkin_date");
|
||||
CREATE INDEX "idx_wecom_checkin_daily_summaries_date" ON "wecom_checkin_daily_summaries"("checkin_date");
|
||||
CREATE INDEX "idx_wecom_checkin_daily_summaries_run" ON "wecom_checkin_daily_summaries"("last_sync_run_id");
|
||||
|
||||
CREATE UNIQUE INDEX "wecom_checkin_monthly_summaries_emp_month_ux" ON "wecom_checkin_monthly_summaries"("employee_id", "month");
|
||||
CREATE INDEX "idx_wecom_checkin_monthly_summaries_month" ON "wecom_checkin_monthly_summaries"("month");
|
||||
|
||||
CREATE UNIQUE INDEX "wecom_checkin_rule_snapshots_emp_date_hash_ux" ON "wecom_checkin_rule_snapshots"("employee_id", "snapshot_date", "rule_hash");
|
||||
CREATE INDEX "idx_wecom_checkin_rule_snapshots_date" ON "wecom_checkin_rule_snapshots"("snapshot_date");
|
||||
CREATE INDEX "idx_wecom_checkin_rule_snapshots_hash" ON "wecom_checkin_rule_snapshots"("rule_hash");
|
||||
|
||||
ALTER TABLE "wecom_checkin_daily_summaries"
|
||||
ADD CONSTRAINT "wecom_checkin_daily_summaries_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "wecom_checkin_monthly_summaries"
|
||||
ADD CONSTRAINT "wecom_checkin_monthly_summaries_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "wecom_checkin_rule_snapshots"
|
||||
ADD CONSTRAINT "wecom_checkin_rule_snapshots_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+82
-2
@@ -619,6 +619,9 @@ model Employee {
|
||||
leave_requests LeaveRequest[]
|
||||
leave_ledgers LeaveLedger[]
|
||||
wecom_checkin_records WecomCheckinRecord[]
|
||||
wecom_checkin_daily_summaries WecomCheckinDailySummary[]
|
||||
wecom_checkin_monthly_summaries WecomCheckinMonthlySummary[]
|
||||
wecom_checkin_rule_snapshots WecomCheckinRuleSnapshot[]
|
||||
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
|
||||
wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunEmployee")
|
||||
triggered_wecom_directory_sync_runs WecomDirectorySyncRun[] @relation("WecomDirectorySyncRunTrigger")
|
||||
@@ -704,7 +707,7 @@ model PasswordResetCode {
|
||||
created_ip String?
|
||||
created_user_agent String?
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([employee_id, created_at])
|
||||
@@index([expires_at])
|
||||
@@ -718,7 +721,7 @@ model EmployeeRole {
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([employee_id, role_code])
|
||||
@@map("employee_roles")
|
||||
@@ -1998,6 +2001,83 @@ model WecomCheckinRecord {
|
||||
@@map("wecom_checkin_records")
|
||||
}
|
||||
|
||||
model WecomCheckinDailySummary {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
external_user_id_prefix String? @db.VarChar(32)
|
||||
checkin_date DateTime @db.Date
|
||||
record_count Int @default(0)
|
||||
first_checkin_at DateTime?
|
||||
last_checkin_at DateTime?
|
||||
exception_count Int @default(0)
|
||||
abnormal_count Int @default(0)
|
||||
checkin_type_summary Json?
|
||||
result_summary Json?
|
||||
source_type_summary Json?
|
||||
raw_hash_digest String? @db.VarChar(64)
|
||||
source_record_count Int @default(0)
|
||||
last_sync_run_id String? @db.Uuid
|
||||
archived_at DateTime @default(now())
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([employee_id, checkin_date], map: "wecom_checkin_daily_summaries_emp_date_ux")
|
||||
@@index([checkin_date], map: "idx_wecom_checkin_daily_summaries_date")
|
||||
@@index([last_sync_run_id], map: "idx_wecom_checkin_daily_summaries_run")
|
||||
@@map("wecom_checkin_daily_summaries")
|
||||
}
|
||||
|
||||
model WecomCheckinMonthlySummary {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
month String @db.VarChar(7)
|
||||
start_date DateTime @db.Date
|
||||
end_date DateTime @db.Date
|
||||
summary_day_count Int @default(0)
|
||||
checkin_day_count Int @default(0)
|
||||
exception_day_count Int @default(0)
|
||||
abnormal_record_count Int @default(0)
|
||||
missing_day_candidate_count Int @default(0)
|
||||
first_checkin_at DateTime?
|
||||
last_checkin_at DateTime?
|
||||
result_summary Json?
|
||||
raw_hash_digest String? @db.VarChar(64)
|
||||
archived_at DateTime @default(now())
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([employee_id, month], map: "wecom_checkin_monthly_summaries_emp_month_ux")
|
||||
@@index([month], map: "idx_wecom_checkin_monthly_summaries_month")
|
||||
@@map("wecom_checkin_monthly_summaries")
|
||||
}
|
||||
|
||||
model WecomCheckinRuleSnapshot {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String? @db.Uuid
|
||||
external_user_id_prefix String? @db.VarChar(32)
|
||||
snapshot_date DateTime @db.Date
|
||||
rule_hash String @db.VarChar(64)
|
||||
rule_summary Json?
|
||||
source_endpoint String @default("/cgi-bin/checkin/getcheckinoption") @db.VarChar(120)
|
||||
archived_at DateTime @default(now())
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee? @relation(fields: [employee_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([employee_id, snapshot_date, rule_hash], map: "wecom_checkin_rule_snapshots_emp_date_hash_ux")
|
||||
@@index([snapshot_date], map: "idx_wecom_checkin_rule_snapshots_date")
|
||||
@@index([rule_hash], map: "idx_wecom_checkin_rule_snapshots_hash")
|
||||
@@map("wecom_checkin_rule_snapshots")
|
||||
}
|
||||
|
||||
model WecomDirectorySyncRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
|
||||
@@ -20,6 +20,14 @@ const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"
|
||||
const DEFAULT_APPLICANT_EMPLOYEE_NO = "SMOKE002";
|
||||
const DEFAULT_APPROVER_EMPLOYEE_NO = "SMOKE101";
|
||||
const WRITE_CONFIRM_ENV = "WECOM_APPROVAL_FIXTURE_WRITE";
|
||||
const EXTENDED_APPROVAL_TARGETS = Object.freeze([
|
||||
{ key: "outing", label: "外出" },
|
||||
{ key: "overtime", label: "加班" },
|
||||
]);
|
||||
const ALL_APPROVAL_FIXTURE_TARGETS = Object.freeze([
|
||||
...APPROVAL_POC_TARGETS,
|
||||
...EXTENDED_APPROVAL_TARGETS,
|
||||
]);
|
||||
|
||||
const REPORT_PRIVACY =
|
||||
"approval fixture counts/masked userid/template_id/sp_no prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
|
||||
@@ -36,6 +44,8 @@ Options:
|
||||
--submit-test-approvals live 模式下允许提交低风险测试审批单。
|
||||
--applicant-employee-no=<no> 申请人员工号,默认 SMOKE002。
|
||||
--approver-employee-no=<no> 审批人员工号,默认 SMOKE101。
|
||||
--target=<key> 样件类型,可重复;默认 leave + missed_punch。
|
||||
--targets=<a,b> 样件类型列表;支持 leave, missed_punch, outing, overtime。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-fixtures-<timestamp>。
|
||||
--help 显示帮助。
|
||||
@@ -77,6 +87,7 @@ function parseArgs(argv) {
|
||||
submitTestApprovals: false,
|
||||
applicantEmployeeNo: DEFAULT_APPLICANT_EMPLOYEE_NO,
|
||||
approverEmployeeNo: DEFAULT_APPROVER_EMPLOYEE_NO,
|
||||
targets: [],
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-fixtures-${timestamp()}`),
|
||||
help: false,
|
||||
@@ -104,6 +115,16 @@ function parseArgs(argv) {
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--approver-employee-no=")) {
|
||||
options.approverEmployeeNo = toSafeString(arg.slice("--approver-employee-no=".length), 80);
|
||||
} else if (arg === "--target") {
|
||||
options.targets.push(toSafeString(argv[index + 1], 40));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--target=")) {
|
||||
options.targets.push(toSafeString(arg.slice("--target=".length), 40));
|
||||
} else if (arg === "--targets") {
|
||||
options.targets.push(...toSafeString(argv[index + 1], 200).split(","));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--targets=")) {
|
||||
options.targets.push(...toSafeString(arg.slice("--targets=".length), 200).split(","));
|
||||
} else if (arg === "--format") {
|
||||
options.format = argv[index + 1] || options.format;
|
||||
index += 1;
|
||||
@@ -128,6 +149,14 @@ function parseArgs(argv) {
|
||||
if (!options.approverEmployeeNo) {
|
||||
throw new Error("--approver-employee-no requires non-empty value");
|
||||
}
|
||||
const supportedTargets = new Set(ALL_APPROVAL_FIXTURE_TARGETS.map((item) => item.key));
|
||||
const normalizedTargets = Array.from(new Set(options.targets.map((item) => toSafeString(item, 40)).filter(Boolean)));
|
||||
for (const target of normalizedTargets) {
|
||||
if (!supportedTargets.has(target)) {
|
||||
throw new Error(`Unsupported --target: ${target}`);
|
||||
}
|
||||
}
|
||||
options.targets = normalizedTargets.length ? normalizedTargets : APPROVAL_POC_TARGETS.map((item) => item.key);
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -136,7 +165,7 @@ function localized(text) {
|
||||
}
|
||||
|
||||
function targetLabel(targetKey) {
|
||||
return APPROVAL_POC_TARGETS.find((item) => item.key === targetKey)?.label || targetKey;
|
||||
return ALL_APPROVAL_FIXTURE_TARGETS.find((item) => item.key === targetKey)?.label || targetKey;
|
||||
}
|
||||
|
||||
function fixtureTimestamp(generatedAt) {
|
||||
@@ -384,10 +413,13 @@ function buildActorPrecheck(rows, options) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildFixturePlans(actorCheck, generatedAt) {
|
||||
function buildFixturePlans(actorCheck, generatedAt, targetKeys = APPROVAL_POC_TARGETS.map((item) => item.key)) {
|
||||
const applicantUserId = actorCheck.internalActors.applicant?.wecomExternalUserId || "";
|
||||
const approverUserId = actorCheck.internalActors.approver?.wecomExternalUserId || "";
|
||||
return APPROVAL_POC_TARGETS.map((target) => {
|
||||
const targets = targetKeys
|
||||
.map((key) => ALL_APPROVAL_FIXTURE_TARGETS.find((item) => item.key === key))
|
||||
.filter(Boolean);
|
||||
return targets.map((target) => {
|
||||
const templatePayload = buildTemplateFixturePayload(target, generatedAt);
|
||||
const applyPayload = buildApplyFixturePayload({
|
||||
target,
|
||||
@@ -505,6 +537,16 @@ async function executeAutoRerun({ liveWrite, executeApprovalPocImpl, generatedAt
|
||||
wouldRun: "check:wecom-approval-poc",
|
||||
};
|
||||
}
|
||||
const pocTargets = new Set(APPROVAL_POC_TARGETS.map((item) => item.key));
|
||||
const unsupportedTargets = templateEntries.map(([key]) => key).filter((key) => !pocTargets.has(key));
|
||||
if (unsupportedTargets.length) {
|
||||
return {
|
||||
status: "SKIPPED",
|
||||
reason: "auto_rerun_only_supports_leave_and_missed_punch",
|
||||
unsupportedTargets,
|
||||
wouldRun: "check:wecom-approval-poc",
|
||||
};
|
||||
}
|
||||
|
||||
const templates = Object.fromEntries(templateEntries);
|
||||
const spNos = Object.fromEntries(spNoEntries);
|
||||
@@ -646,7 +688,7 @@ async function executeApprovalFixtures(options, deps = {}) {
|
||||
actorCheck.gaps.push({ id: "database_read_unavailable", employeeNo: null, message: actorReadError });
|
||||
}
|
||||
|
||||
const fixturePlans = buildFixturePlans(actorCheck, generatedAt);
|
||||
const fixturePlans = buildFixturePlans(actorCheck, generatedAt, options.targets);
|
||||
const writeRequested = liveWriteRequested(options);
|
||||
const writeAllowed = liveWriteAllowed(options, env);
|
||||
let liveWrite = {
|
||||
@@ -696,7 +738,7 @@ async function executeApprovalFixtures(options, deps = {}) {
|
||||
privacy: REPORT_PRIVACY,
|
||||
scope: {
|
||||
issue: "WAPC2F",
|
||||
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
|
||||
targets: options.targets,
|
||||
applicantEmployeeNo: options.applicantEmployeeNo,
|
||||
approverEmployeeNo: options.approverEmployeeNo,
|
||||
templateDepth: "minimal_generic",
|
||||
@@ -782,6 +824,7 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildActorPrecheck,
|
||||
ALL_APPROVAL_FIXTURE_TARGETS,
|
||||
buildApplyFixturePayload,
|
||||
buildSecretCheck,
|
||||
buildTemplateFixturePayload,
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const {
|
||||
fetchWecomCheckinData,
|
||||
fetchWecomCheckinRules,
|
||||
formatDateInTimezone,
|
||||
normalizeCheckinRecord,
|
||||
resolveCheckinSecret,
|
||||
} = require("../services/integrations/wecomCheckin");
|
||||
const {
|
||||
archiveCheckinSummaries,
|
||||
buildRuleSnapshots,
|
||||
maskExternalUserId,
|
||||
readCheckinArchiveSnapshot,
|
||||
sanitizeDailySummary,
|
||||
sanitizeMonthlySummary,
|
||||
sanitizeRuleSnapshot,
|
||||
} = require("../services/integrations/wecomCheckinArchive");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_EMPLOYEE_NOS = Object.freeze(["SMOKE002", "SMOKE101"]);
|
||||
const WRITE_CONFIRM_ENV = "WECOM_CHECKIN_ARCHIVE_WRITE";
|
||||
const REPORT_PRIVACY =
|
||||
"checkin counts/date/status/exception summaries/masked userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/location/face/device/raw payload";
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/check-wecom-checkin-summary-archive.js [options]
|
||||
|
||||
QA-only 企微打卡摘要留存 runner。
|
||||
默认 offline:只读 OA DB 摘要,不调用企微 API,不写 OA/企微数据。
|
||||
|
||||
Options:
|
||||
--live 调用企微打卡只读接口。
|
||||
--offline 只读 OA DB 摘要,默认。
|
||||
--persist-to-db 将规范化打卡记录、日/月摘要和规则快照写入 OA DB。
|
||||
--employee-no=<no> 测试员工号,可重复;默认 SMOKE002 + SMOKE101。
|
||||
--start-date=<YYYY-MM-DD> 起始日期,默认最近 1 天。
|
||||
--end-date=<YYYY-MM-DD> 截止日期,默认今天。
|
||||
--include-rules live 模式下读取打卡规则快照,默认开启。
|
||||
--no-include-rules live 模式下不读取规则。
|
||||
--limit=<1..200> 离线读取条数,默认 50。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-checkin-summary-archive-<timestamp>。
|
||||
--help 显示帮助。
|
||||
|
||||
写 OA 留存必须同时满足:
|
||||
1) --persist-to-db
|
||||
2) ${WRITE_CONFIRM_ENV}=1
|
||||
|
||||
Secret 固定规则:
|
||||
打卡读取:WECOM_AGENT_SECRET,只有显式 WECOM_CHECKIN_SECRET 时覆盖。
|
||||
不使用 WECOM_CONTACT_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
|
||||
|
||||
隐私边界:
|
||||
报告只输出计数、日期、状态、异常摘要和脱敏 userid 前缀。
|
||||
不输出手机号、邮箱、token、secret、完整 userid、位置、人脸、设备明文或企微原始 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 parseArgs(argv) {
|
||||
const options = {
|
||||
live: false,
|
||||
offline: true,
|
||||
persistToDb: false,
|
||||
employeeNos: [],
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
includeRules: true,
|
||||
limit: 50,
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-checkin-summary-archive-${timestamp()}`),
|
||||
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;
|
||||
options.offline = false;
|
||||
} else if (arg === "--offline") {
|
||||
options.offline = true;
|
||||
options.live = false;
|
||||
} else if (arg === "--persist-to-db") {
|
||||
options.persistToDb = true;
|
||||
} else if (arg === "--employee-no") {
|
||||
options.employeeNos.push(toSafeString(argv[index + 1], 80));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--employee-no=")) {
|
||||
options.employeeNos.push(toSafeString(arg.slice("--employee-no=".length), 80));
|
||||
} else if (arg === "--start-date") {
|
||||
options.startDate = toSafeString(argv[index + 1], 20);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--start-date=")) {
|
||||
options.startDate = toSafeString(arg.slice("--start-date=".length), 20);
|
||||
} else if (arg === "--end-date") {
|
||||
options.endDate = toSafeString(argv[index + 1], 20);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--end-date=")) {
|
||||
options.endDate = toSafeString(arg.slice("--end-date=".length), 20);
|
||||
} else if (arg === "--include-rules") {
|
||||
options.includeRules = true;
|
||||
} else if (arg === "--no-include-rules") {
|
||||
options.includeRules = false;
|
||||
} else if (arg === "--limit") {
|
||||
options.limit = Number(argv[index + 1] || options.limit);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--limit=")) {
|
||||
options.limit = Number(arg.slice("--limit=".length));
|
||||
} 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 {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 200) {
|
||||
throw new Error("--limit must be between 1 and 200");
|
||||
}
|
||||
options.limit = Math.floor(options.limit);
|
||||
options.employeeNos = Array.from(new Set((options.employeeNos.length ? options.employeeNos : DEFAULT_EMPLOYEE_NOS).filter(Boolean)));
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildDateWindow(options, generatedAt, timeZone = "Asia/Shanghai") {
|
||||
const end = options.endDate || formatDateInTimezone(new Date(generatedAt), timeZone);
|
||||
const start = options.startDate || formatDateInTimezone(new Date(new Date(`${end}T00:00:00+08:00`).getTime() - 24 * 60 * 60 * 1000), timeZone);
|
||||
return { startDate: start, endDate: end };
|
||||
}
|
||||
|
||||
function buildSecretCheck(config) {
|
||||
const source = config?.wecomCheckinSecret ? "WECOM_CHECKIN_SECRET" : "WECOM_AGENT_SECRET";
|
||||
const configured = Boolean(resolveCheckinSecret(config));
|
||||
return {
|
||||
id: "checkin_archive_secret_matrix",
|
||||
source,
|
||||
endpoint: "POST /cgi-bin/checkin/getcheckindata and /cgi-bin/checkin/getcheckinoption",
|
||||
configured,
|
||||
override: source === "WECOM_CHECKIN_SECRET",
|
||||
noFallback: ["WECOM_CONTACT_SECRET", "WECOM_HR_ASSISTANT_SECRET"],
|
||||
missing: configured ? [] : ["WECOM_AGENT_SECRET", "WECOM_CHECKIN_SECRET"],
|
||||
};
|
||||
}
|
||||
|
||||
function buildWriteGate(options, env) {
|
||||
if (!options.persistToDb) {
|
||||
return {
|
||||
id: "checkin_archive_write_gate",
|
||||
status: "DRY_RUN",
|
||||
enabled: false,
|
||||
reason: "run_with_--persist-to-db_and_WECOM_CHECKIN_ARCHIVE_WRITE=1_to_write_OA_archive",
|
||||
};
|
||||
}
|
||||
if (env[WRITE_CONFIRM_ENV] !== "1") {
|
||||
return {
|
||||
id: "checkin_archive_write_gate",
|
||||
status: "NEEDS_CONFIG",
|
||||
enabled: false,
|
||||
missing: [WRITE_CONFIRM_ENV],
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "checkin_archive_write_gate",
|
||||
status: "OK",
|
||||
enabled: true,
|
||||
target: "OA_DB_only",
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeEmployee(row) {
|
||||
return {
|
||||
employeeNo: row.employee_no,
|
||||
status: row.status,
|
||||
archived: Boolean(row.archived_at),
|
||||
hasWecomBinding: Boolean(row.wecom_identity_binding?.external_user_id),
|
||||
userIdPrefix: maskExternalUserId(row.wecom_identity_binding?.external_user_id),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEmployees(prisma, employeeNos) {
|
||||
return prisma.employee.findMany({
|
||||
where: {
|
||||
employee_no: { in: employeeNos },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
status: true,
|
||||
archived_at: true,
|
||||
wecom_identity_binding: {
|
||||
select: { external_user_id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { employee_no: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
function buildEmployeeCheck(rows, employeeNos) {
|
||||
const byNo = new Map((rows || []).map((row) => [row.employee_no, row]));
|
||||
const gaps = [];
|
||||
for (const employeeNo of employeeNos) {
|
||||
const row = byNo.get(employeeNo);
|
||||
if (!row) {
|
||||
gaps.push({ id: "employee_missing", employeeNo });
|
||||
continue;
|
||||
}
|
||||
if (row.status !== "active" || row.archived_at) {
|
||||
gaps.push({ id: "employee_inactive", employeeNo });
|
||||
}
|
||||
if (!row.wecom_identity_binding?.external_user_id) {
|
||||
gaps.push({ id: "employee_missing_wecom_binding", employeeNo });
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: "checkin_archive_employee_scope",
|
||||
status: gaps.length ? "NEEDS_CONFIG" : "OK",
|
||||
scope: employeeNos,
|
||||
employees: rows.map(sanitizeEmployee),
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistLiveRecords({ prisma, employees, normalizedRecords, window }) {
|
||||
const syncRun = await prisma.wecomCheckinSyncRun.create({
|
||||
data: {
|
||||
start_date: new Date(`${window.startDate}T00:00:00.000Z`),
|
||||
end_date: new Date(`${window.endDate}T00:00:00.000Z`),
|
||||
status: "archive_running",
|
||||
employee_count: employees.length,
|
||||
record_count: 0,
|
||||
},
|
||||
});
|
||||
if (normalizedRecords.length) {
|
||||
await prisma.wecomCheckinRecord.createMany({
|
||||
data: normalizedRecords.map((record) => ({
|
||||
sync_run_id: syncRun.id,
|
||||
employee_id: record.employeeId,
|
||||
external_user_id: record.externalUserId,
|
||||
checkin_time: record.checkinTime,
|
||||
checkin_date: record.checkinDate,
|
||||
checkin_type: record.checkinType,
|
||||
exception_type: record.exceptionType,
|
||||
source_type: record.sourceType,
|
||||
result: record.result,
|
||||
raw_hash: record.rawHash,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
await prisma.wecomCheckinSyncRun.update({
|
||||
where: { id: syncRun.id },
|
||||
data: {
|
||||
status: "archive_success",
|
||||
record_count: normalizedRecords.length,
|
||||
},
|
||||
});
|
||||
return syncRun;
|
||||
}
|
||||
|
||||
async function loadRecordsForSummary({ prisma, employeeIds, window }) {
|
||||
return prisma.wecomCheckinRecord.findMany({
|
||||
where: {
|
||||
employee_id: { in: employeeIds },
|
||||
checkin_date: {
|
||||
gte: new Date(`${window.startDate}T00:00:00.000Z`),
|
||||
lte: new Date(`${window.endDate}T00:00:00.000Z`),
|
||||
},
|
||||
},
|
||||
orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async function runLiveCheck({
|
||||
config,
|
||||
prisma,
|
||||
employees,
|
||||
employeeCheck,
|
||||
window,
|
||||
options,
|
||||
writeGate,
|
||||
fetchCheckinDataImpl,
|
||||
fetchCheckinRulesImpl,
|
||||
}) {
|
||||
if (employeeCheck.status !== "OK") {
|
||||
return {
|
||||
status: "NEEDS_CONFIG",
|
||||
reason: "employee_scope_not_ready",
|
||||
records: [],
|
||||
ruleSnapshots: [],
|
||||
api: null,
|
||||
rulesApi: null,
|
||||
persisted: null,
|
||||
};
|
||||
}
|
||||
const externalUserIds = employees.map((row) => row.wecom_identity_binding?.external_user_id).filter(Boolean);
|
||||
const employeeByExternalUserId = new Map(
|
||||
employees.map((row) => [row.wecom_identity_binding?.external_user_id, { id: row.id, employeeNo: row.employee_no }]).filter(([key]) => key),
|
||||
);
|
||||
const api = await fetchCheckinDataImpl({
|
||||
config,
|
||||
startDate: window.startDate,
|
||||
endDate: window.endDate,
|
||||
externalUserIds,
|
||||
requireSyncEnabled: false,
|
||||
});
|
||||
if (!api.ok) {
|
||||
return {
|
||||
status: "BLOCKED",
|
||||
reason: api.code || "wecom_checkin_api_blocked",
|
||||
message: api.message ? safeMessage(api.message) : null,
|
||||
records: [],
|
||||
ruleSnapshots: [],
|
||||
api: {
|
||||
code: api.code,
|
||||
response: api.response,
|
||||
},
|
||||
rulesApi: null,
|
||||
persisted: null,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedRecords = (api.records || [])
|
||||
.map((raw) => normalizeCheckinRecord(raw, {
|
||||
employeeByExternalUserId,
|
||||
timeZone: config.appTimezone || "Asia/Shanghai",
|
||||
}))
|
||||
.filter(Boolean);
|
||||
|
||||
let rulesApi = null;
|
||||
let ruleSnapshots = [];
|
||||
if (options.includeRules) {
|
||||
rulesApi = await fetchCheckinRulesImpl({
|
||||
config,
|
||||
date: window.endDate,
|
||||
externalUserIds,
|
||||
requireSyncEnabled: false,
|
||||
});
|
||||
if (rulesApi.ok) {
|
||||
ruleSnapshots = buildRuleSnapshots({
|
||||
rules: rulesApi.rules || [],
|
||||
employeeByExternalUserId,
|
||||
snapshotDate: window.endDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let persisted = {
|
||||
enabled: false,
|
||||
dailyCount: 0,
|
||||
monthlyCount: 0,
|
||||
ruleSnapshotCount: 0,
|
||||
sourceRecordCount: normalizedRecords.length,
|
||||
};
|
||||
if (writeGate.enabled) {
|
||||
const syncRun = await persistLiveRecords({ prisma, employees, normalizedRecords, window });
|
||||
const persistedRecords = await loadRecordsForSummary({
|
||||
prisma,
|
||||
employeeIds: employees.map((row) => row.id),
|
||||
window,
|
||||
});
|
||||
const archive = await archiveCheckinSummaries({
|
||||
prisma,
|
||||
records: persistedRecords,
|
||||
ruleSnapshots,
|
||||
});
|
||||
persisted = {
|
||||
enabled: true,
|
||||
syncRunId: syncRun.id,
|
||||
sourceRecordCount: persistedRecords.length,
|
||||
dailyCount: archive.dailyCount,
|
||||
monthlyCount: archive.monthlyCount,
|
||||
ruleSnapshotCount: archive.ruleSnapshotCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "OK",
|
||||
reason: api.code,
|
||||
records: normalizedRecords,
|
||||
ruleSnapshots,
|
||||
api: {
|
||||
code: api.code,
|
||||
recordCount: normalizedRecords.length,
|
||||
response: api.response,
|
||||
},
|
||||
rulesApi: rulesApi ? {
|
||||
code: rulesApi.code,
|
||||
ok: Boolean(rulesApi.ok),
|
||||
ruleCount: Array.isArray(rulesApi.rules) ? rulesApi.rules.length : 0,
|
||||
response: rulesApi.response,
|
||||
} : null,
|
||||
persisted,
|
||||
};
|
||||
}
|
||||
|
||||
async function runOfflineCheck({ prisma, employees, window, options }) {
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = await readCheckinArchiveSnapshot(prisma, {
|
||||
startDate: window.startDate,
|
||||
endDate: window.endDate,
|
||||
employeeIds: employees.map((row) => row.id),
|
||||
limit: options.limit,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "ERROR",
|
||||
reason: "database_read_unavailable",
|
||||
message: safeMessage(error?.message || error),
|
||||
counts: { daily: 0, monthly: 0, rules: 0 },
|
||||
samples: { daily: [], monthly: [], rules: [] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: snapshot.daily.length || snapshot.monthly.length || snapshot.rules.length
|
||||
? "OK"
|
||||
: "EMPTY",
|
||||
counts: {
|
||||
daily: snapshot.daily.length,
|
||||
monthly: snapshot.monthly.length,
|
||||
rules: snapshot.rules.length,
|
||||
},
|
||||
samples: {
|
||||
daily: snapshot.daily.slice(0, 5).map(sanitizeDailySummary),
|
||||
monthly: snapshot.monthly.slice(0, 5).map(sanitizeMonthlySummary),
|
||||
rules: snapshot.rules.slice(0, 5).map(sanitizeRuleSnapshot),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function evaluateConclusion({ secretCheck, employeeCheck, writeGate, liveCheck, offlineCheck, options }) {
|
||||
if (!secretCheck.configured && options.live) {
|
||||
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
||||
}
|
||||
if (liveCheck?.status === "BLOCKED") {
|
||||
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
|
||||
}
|
||||
if (offlineCheck?.status === "ERROR") {
|
||||
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG" };
|
||||
}
|
||||
if (employeeCheck.status !== "OK" || writeGate.status === "NEEDS_CONFIG") {
|
||||
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG" };
|
||||
}
|
||||
if (options.offline && offlineCheck?.status === "EMPTY") {
|
||||
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA" };
|
||||
}
|
||||
if (options.live && liveCheck?.status === "OK") {
|
||||
return { status: "PASS_CHECKIN_ARCHIVE_READY", conclusion: "PASS_CHECKIN_ARCHIVE_READY" };
|
||||
}
|
||||
if (offlineCheck?.status === "OK") {
|
||||
return { status: "PASS_CHECKIN_ARCHIVE_OFFLINE_READY", conclusion: "PASS_CHECKIN_ARCHIVE_OFFLINE_READY" };
|
||||
}
|
||||
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA" };
|
||||
}
|
||||
|
||||
async function executeCheckinSummaryArchive(options, deps = {}) {
|
||||
const env = deps.env || process.env;
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const window = buildDateWindow(options, generatedAt, config.appTimezone || "Asia/Shanghai");
|
||||
const secretCheck = buildSecretCheck(config);
|
||||
const writeGate = buildWriteGate(options, env);
|
||||
let ownedPrisma = null;
|
||||
let ownedPool = null;
|
||||
|
||||
try {
|
||||
let prisma = deps.prisma;
|
||||
if (!prisma) {
|
||||
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
|
||||
ownedPrisma = created.prisma;
|
||||
ownedPool = created.pool;
|
||||
prisma = ownedPrisma;
|
||||
}
|
||||
|
||||
let employees = deps.employees;
|
||||
let employeeReadError = null;
|
||||
if (!employees) {
|
||||
try {
|
||||
employees = await loadEmployees(prisma, options.employeeNos);
|
||||
} catch (error) {
|
||||
employeeReadError = safeMessage(error?.message || error);
|
||||
employees = [];
|
||||
}
|
||||
}
|
||||
const employeeCheck = employeeReadError
|
||||
? {
|
||||
id: "checkin_archive_employee_scope",
|
||||
status: "NEEDS_CONFIG",
|
||||
scope: options.employeeNos,
|
||||
employees: [],
|
||||
gaps: [{ id: "database_read_unavailable", message: employeeReadError }],
|
||||
}
|
||||
: buildEmployeeCheck(employees, options.employeeNos);
|
||||
const liveCheck = options.live
|
||||
? await runLiveCheck({
|
||||
config,
|
||||
prisma,
|
||||
employees,
|
||||
employeeCheck,
|
||||
window,
|
||||
options,
|
||||
writeGate,
|
||||
fetchCheckinDataImpl: deps.fetchCheckinDataImpl || fetchWecomCheckinData,
|
||||
fetchCheckinRulesImpl: deps.fetchCheckinRulesImpl || fetchWecomCheckinRules,
|
||||
})
|
||||
: null;
|
||||
const offlineCheck = await runOfflineCheck({ prisma, employees, window, options });
|
||||
const conclusion = evaluateConclusion({ secretCheck, employeeCheck, writeGate, liveCheck, offlineCheck, options });
|
||||
|
||||
return {
|
||||
issueId: "WCHECK1",
|
||||
mode: "wecom-checkin-summary-archive",
|
||||
generatedAt,
|
||||
status: conclusion.status,
|
||||
conclusion: conclusion.conclusion,
|
||||
window,
|
||||
scope: {
|
||||
employeeNos: options.employeeNos,
|
||||
live: options.live,
|
||||
offline: options.offline || !options.live,
|
||||
includeRules: options.includeRules,
|
||||
writeBehavior: writeGate.enabled ? "OA DB archive write enabled by CLI and env" : "read-only/offline unless --persist-to-db and WECOM_CHECKIN_ARCHIVE_WRITE=1",
|
||||
},
|
||||
checks: {
|
||||
secretMatrix: secretCheck,
|
||||
employees: employeeCheck,
|
||||
writeGate,
|
||||
live: liveCheck ? {
|
||||
status: liveCheck.status,
|
||||
reason: liveCheck.reason,
|
||||
message: liveCheck.message || null,
|
||||
api: liveCheck.api,
|
||||
rulesApi: liveCheck.rulesApi,
|
||||
recordCount: liveCheck.records.length,
|
||||
ruleSnapshotCount: liveCheck.ruleSnapshots.length,
|
||||
persisted: liveCheck.persisted,
|
||||
} : null,
|
||||
offline: offlineCheck,
|
||||
},
|
||||
privacy: REPORT_PRIVACY,
|
||||
};
|
||||
} finally {
|
||||
if (ownedPrisma) await ownedPrisma.$disconnect().catch(() => {});
|
||||
if (ownedPool) await ownedPool.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function writeReport(reportDir, report) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
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-checkin-summary-archive] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-checkin-summary-archive] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-checkin-summary-archive] privacy=${report.privacy}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeCheckinSummaryArchive(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-checkin-summary-archive][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDateWindow,
|
||||
buildSecretCheck,
|
||||
buildWriteGate,
|
||||
executeCheckinSummaryArchive,
|
||||
parseArgs,
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env node
|
||||
require("dotenv/config");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const { getConfig } = require("../config");
|
||||
const { sendWecomMessage } = require("../services/integrations/im/wecom");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_RECIPIENTS = Object.freeze(["wangshunli", "HYD000006"]);
|
||||
const SEND_CONFIRM_ENV = "WARCH3_OPS_ALERT_SEND";
|
||||
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/send-wecom-ops-alert.js [options]
|
||||
|
||||
QA-only WARCH3 企微巡检脱敏告警。
|
||||
默认 dry-run:只生成预览和报告,不发送企微消息。
|
||||
|
||||
Options:
|
||||
--summary-file=<path> WARCH3/WROAD2 summary.json。
|
||||
--recipient-employee-no=<no> 收件员工号,可重复;默认 wangshunli + HYD000006。
|
||||
--send 发送企微应用消息。
|
||||
--force PASS 且无 finding 时也发送测试消息。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-ops-alert-<timestamp>。
|
||||
--help 显示帮助。
|
||||
|
||||
发送必须同时满足:
|
||||
1) --send
|
||||
2) ${SEND_CONFIRM_ENV}=1
|
||||
|
||||
隐私边界:
|
||||
消息和报告只包含阶段、状态、finding 数、报告路径和下一步建议。
|
||||
不输出手机号、邮箱、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 maskIdentifier(value) {
|
||||
const raw = toSafeString(value, 160);
|
||||
if (!raw) return null;
|
||||
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
|
||||
return `${raw.slice(0, visibleLength)}***`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
summaryFile: "",
|
||||
recipientEmployeeNos: [],
|
||||
send: false,
|
||||
force: false,
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-ops-alert-${timestamp()}`),
|
||||
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 === "--summary-file") {
|
||||
options.summaryFile = toSafeString(argv[index + 1], 1000);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--summary-file=")) {
|
||||
options.summaryFile = toSafeString(arg.slice("--summary-file=".length), 1000);
|
||||
} else if (arg === "--recipient-employee-no") {
|
||||
options.recipientEmployeeNos.push(toSafeString(argv[index + 1], 80));
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--recipient-employee-no=")) {
|
||||
options.recipientEmployeeNos.push(toSafeString(arg.slice("--recipient-employee-no=".length), 80));
|
||||
} else if (arg === "--send") {
|
||||
options.send = true;
|
||||
} else if (arg === "--force") {
|
||||
options.force = true;
|
||||
} 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 {
|
||||
throw new Error(`Unsupported argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!["text", "json"].includes(options.format)) {
|
||||
throw new Error(`Unsupported --format: ${options.format}`);
|
||||
}
|
||||
options.recipientEmployeeNos = Array.from(new Set(
|
||||
(options.recipientEmployeeNos.length ? options.recipientEmployeeNos : DEFAULT_RECIPIENTS).filter(Boolean),
|
||||
));
|
||||
if (!options.recipientEmployeeNos.length) {
|
||||
throw new Error("--recipient-employee-no requires non-empty value");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function assertAlertSendAllowed(options, env = process.env) {
|
||||
if (!options.send) return;
|
||||
if (env[SEND_CONFIRM_ENV] !== "1") {
|
||||
throw new Error(`Sending WARCH3 ops alert requires ${SEND_CONFIRM_ENV}=1`);
|
||||
}
|
||||
}
|
||||
|
||||
function readSummary(summaryFile) {
|
||||
if (!summaryFile) {
|
||||
return {
|
||||
status: "PARTIAL_ALERT_NEEDS_CONFIG",
|
||||
issueId: "WARCH3",
|
||||
findingCount: 0,
|
||||
reportDir: null,
|
||||
notes: "summary_file_missing",
|
||||
};
|
||||
}
|
||||
const resolved = path.resolve(summaryFile);
|
||||
const raw = fs.readFileSync(resolved, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
reportDir: parsed.reportDir || path.dirname(resolved),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldSendForSummary(summary, options) {
|
||||
if (options.force) return true;
|
||||
const status = toSafeString(summary?.status || summary?.conclusion, 80);
|
||||
return status !== "PASS_ARCHIVE_OPS_READY" || Number(summary?.findingCount || 0) > 0;
|
||||
}
|
||||
|
||||
function nextStepForStatus(summary) {
|
||||
const status = toSafeString(summary?.status || summary?.conclusion, 80);
|
||||
if (status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
|
||||
return "检查 Secret、权限、可信 IP、可见范围或外部配置后复跑。";
|
||||
}
|
||||
if (Number(summary?.findingCount || 0) > 0 || status === "PASS_WITH_FINDINGS") {
|
||||
return "保留 finding 审计,优先处理真实阻断;已知 live smoke/OCR 与历史 callback gap 不阻断归档主链路。";
|
||||
}
|
||||
return "继续按 07:30 每日巡检节奏观察。";
|
||||
}
|
||||
|
||||
function buildAlertMessage(summary) {
|
||||
const issueId = toSafeString(summary?.issueId || "WARCH3", 40) || "WARCH3";
|
||||
const status = toSafeString(summary?.status || summary?.conclusion || "UNKNOWN", 80) || "UNKNOWN";
|
||||
const findingCount = Number(summary?.findingCount || 0);
|
||||
const reportPath = toSafeString(summary?.reportDir || summary?.sourceReports?.wroad2 || "N/A", 500);
|
||||
const nextStep = nextStepForStatus(summary);
|
||||
return {
|
||||
title: `OA 巡检 ${status}`,
|
||||
body: [
|
||||
`阶段:${issueId}`,
|
||||
`状态:${status}`,
|
||||
`finding:${Number.isFinite(findingCount) ? findingCount : 0}`,
|
||||
`报告:${reportPath}`,
|
||||
`建议:${nextStep}`,
|
||||
].join("\n"),
|
||||
footer: "脱敏巡检消息,仅含状态和报告路径。",
|
||||
};
|
||||
}
|
||||
|
||||
async function queryRecipientRows(employeeNos) {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("Missing DATABASE_URL");
|
||||
}
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN READ ONLY");
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
e.employee_no,
|
||||
e.status::text,
|
||||
(e.archived_at IS NOT NULL) AS archived,
|
||||
w.external_user_id AS wecom_external_user_id
|
||||
FROM employees e
|
||||
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
|
||||
WHERE e.employee_no = ANY($1::text[])
|
||||
ORDER BY e.employee_no ASC
|
||||
`, [employeeNos]);
|
||||
await client.query("COMMIT");
|
||||
return result.rows.map((row) => ({
|
||||
employeeNo: toSafeString(row.employee_no, 80),
|
||||
status: toSafeString(row.status, 40),
|
||||
archived: Boolean(row.archived),
|
||||
wecomExternalUserId: toSafeString(row.wecom_external_user_id, 160),
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query("ROLLBACK");
|
||||
} catch {
|
||||
// Preserve original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRecipients(rows) {
|
||||
return (rows || []).map((row) => ({
|
||||
employeeNo: row.employeeNo,
|
||||
status: row.status,
|
||||
archived: Boolean(row.archived),
|
||||
hasWecomBinding: Boolean(row.wecomExternalUserId),
|
||||
wecomUserIdPrefix: maskIdentifier(row.wecomExternalUserId),
|
||||
}));
|
||||
}
|
||||
|
||||
function collectRecipientGaps(rows, employeeNos) {
|
||||
const byNo = new Map((rows || []).map((row) => [row.employeeNo, row]));
|
||||
const gaps = [];
|
||||
for (const employeeNo of employeeNos) {
|
||||
const row = byNo.get(employeeNo);
|
||||
if (!row) {
|
||||
gaps.push({ id: "recipient_missing", employeeNo });
|
||||
continue;
|
||||
}
|
||||
if (row.status !== "active" || row.archived) {
|
||||
gaps.push({ id: "recipient_inactive", employeeNo });
|
||||
}
|
||||
if (!row.wecomExternalUserId) {
|
||||
gaps.push({ id: "recipient_missing_wecom_binding", employeeNo });
|
||||
}
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function maskDelimitedIdentifiers(value) {
|
||||
const raw = toSafeString(value, 1000);
|
||||
if (!raw) return raw;
|
||||
return raw
|
||||
.split(/[|,;\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((item) => maskIdentifier(item))
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function sanitizeDeliveryResponse(response) {
|
||||
if (!response || typeof response !== "object") return response ?? null;
|
||||
const sanitized = Array.isArray(response) ? [] : {};
|
||||
for (const [key, value] of Object.entries(response)) {
|
||||
if (/invaliduser|userid|touser|user_id/i.test(key) && typeof value === "string") {
|
||||
sanitized[key] = maskDelimitedIdentifiers(value);
|
||||
} else if (value && typeof value === "object") {
|
||||
sanitized[key] = sanitizeDeliveryResponse(value);
|
||||
} else if (typeof value === "string") {
|
||||
sanitized[key] = safeMessage(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function executeOpsAlert(options, deps = {}) {
|
||||
const env = deps.env || process.env;
|
||||
assertAlertSendAllowed(options, env);
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const summary = deps.summary || readSummary(options.summaryFile);
|
||||
const message = buildAlertMessage(summary);
|
||||
const shouldSend = shouldSendForSummary(summary, options);
|
||||
|
||||
let recipientRows = deps.recipientRows;
|
||||
let recipientReadError = null;
|
||||
if (!recipientRows) {
|
||||
try {
|
||||
recipientRows = await (deps.queryRecipientRows || queryRecipientRows)(options.recipientEmployeeNos);
|
||||
} catch (error) {
|
||||
recipientReadError = safeMessage(error?.message || error);
|
||||
recipientRows = [];
|
||||
}
|
||||
}
|
||||
|
||||
const recipientGaps = collectRecipientGaps(recipientRows, options.recipientEmployeeNos);
|
||||
if (recipientReadError) {
|
||||
recipientGaps.push({ id: "database_read_unavailable", message: recipientReadError });
|
||||
}
|
||||
const externalUserIds = Array.from(new Set(
|
||||
(recipientRows || [])
|
||||
.filter((row) => row.status === "active" && !row.archived && row.wecomExternalUserId)
|
||||
.map((row) => row.wecomExternalUserId),
|
||||
));
|
||||
|
||||
let delivery = {
|
||||
status: shouldSend ? "DRY_RUN_READY" : "SKIPPED_NO_FINDINGS",
|
||||
code: shouldSend ? "dry_run" : "no_finding_or_failure",
|
||||
response: null,
|
||||
recipientCount: externalUserIds.length,
|
||||
};
|
||||
if (shouldSend && options.send && externalUserIds.length > 0) {
|
||||
const result = await (deps.sendMessageImpl || sendWecomMessage)({
|
||||
config,
|
||||
channel: { config_json: { mode: "wecom_app_message", timeoutMs: config.wecomRequestTimeoutMs ?? 5000 } },
|
||||
message,
|
||||
recipientExternalUserIds: externalUserIds,
|
||||
});
|
||||
delivery = {
|
||||
status: result.ok ? "OK" : "BLOCKED",
|
||||
code: result.code,
|
||||
message: result.message ? safeMessage(result.message) : null,
|
||||
response: sanitizeDeliveryResponse(result.response),
|
||||
recipientCount: result.response?.recipientCount ?? externalUserIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
let status = "PASS_ALERT_DRY_RUN_READY";
|
||||
let conclusion = "PASS_ALERT_DRY_RUN_READY";
|
||||
if (delivery.status === "BLOCKED") {
|
||||
status = "SKIP";
|
||||
conclusion = "BLOCKED_BY_EXTERNAL_CONFIG";
|
||||
} else if (delivery.status === "OK" && recipientGaps.length) {
|
||||
status = "PASS_ALERT_SENT_WITH_CONFIG_GAPS";
|
||||
conclusion = "PASS_ALERT_SENT_WITH_CONFIG_GAPS";
|
||||
} else if (delivery.status === "OK") {
|
||||
status = "PASS_ALERT_SENT";
|
||||
conclusion = "PASS_ALERT_SENT";
|
||||
} else if (recipientGaps.length) {
|
||||
status = "PARTIAL_ALERT_NEEDS_CONFIG";
|
||||
conclusion = "PARTIAL_ALERT_NEEDS_CONFIG";
|
||||
} else if (delivery.status === "SKIPPED_NO_FINDINGS") {
|
||||
status = "PASS_ALERT_SKIPPED_NO_FINDINGS";
|
||||
conclusion = "PASS_ALERT_SKIPPED_NO_FINDINGS";
|
||||
}
|
||||
|
||||
return {
|
||||
issueId: "WARCH3",
|
||||
mode: "wecom-ops-alert",
|
||||
generatedAt,
|
||||
status,
|
||||
conclusion,
|
||||
sendRequested: Boolean(options.send),
|
||||
sendAllowed: Boolean(options.send && env[SEND_CONFIRM_ENV] === "1"),
|
||||
shouldSend,
|
||||
summary: {
|
||||
issueId: summary?.issueId || null,
|
||||
status: summary?.status || summary?.conclusion || "UNKNOWN",
|
||||
findingCount: Number(summary?.findingCount || 0),
|
||||
reportDir: summary?.reportDir || null,
|
||||
},
|
||||
recipients: sanitizeRecipients(recipientRows),
|
||||
recipientGaps,
|
||||
delivery,
|
||||
messagePreview: buildAlertMessage(summary),
|
||||
privacy: "stage/status/finding count/report path/next step only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload",
|
||||
};
|
||||
}
|
||||
|
||||
function writeReport(reportDir, report) {
|
||||
fs.mkdirSync(reportDir, { recursive: true });
|
||||
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-ops-alert] status=${report.status} conclusion=${report.conclusion}\n`);
|
||||
process.stdout.write(`[wecom-ops-alert] report=${reportPath}\n`);
|
||||
process.stdout.write(`[wecom-ops-alert] delivery=${report.delivery.status} code=${report.delivery.code}\n`);
|
||||
if (report.recipientGaps.length) {
|
||||
process.stdout.write(`[wecom-ops-alert] recipientGaps=${JSON.stringify(report.recipientGaps)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const report = await executeOpsAlert(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-ops-alert][FAIL] ${safeMessage(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertAlertSendAllowed,
|
||||
buildAlertMessage,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
sanitizeDeliveryResponse,
|
||||
shouldSendForSummary,
|
||||
};
|
||||
@@ -31,12 +31,37 @@ function hashPayload(value) {
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function maskIdentifier(value) {
|
||||
const raw = toSafeString(value, 128);
|
||||
if (!raw) return "";
|
||||
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
|
||||
return `${raw.slice(0, visibleLength)}***`;
|
||||
}
|
||||
|
||||
function maskDelimitedIdentifiers(value) {
|
||||
const raw = toSafeString(value, 1000);
|
||||
if (!raw) return raw;
|
||||
return raw
|
||||
.split(/[|,;\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(maskIdentifier)
|
||||
.join("|");
|
||||
}
|
||||
|
||||
function sanitizeCheckinApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
for (const key of ["invaliduser", "invalidparty", "invalidtag"]) {
|
||||
if (typeof result[key] === "string") {
|
||||
result[key] = maskDelimitedIdentifiers(result[key]);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(payload.checkindata)) {
|
||||
result.checkindataCount = payload.checkindata.length;
|
||||
}
|
||||
if (Array.isArray(payload.info)) {
|
||||
result.ruleInfoCount = payload.info.length;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
@@ -182,9 +207,121 @@ async function fetchWecomCheckinData({
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomCheckinRules({
|
||||
config,
|
||||
date,
|
||||
externalUserIds,
|
||||
requireSyncEnabled = false,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const userIds = Array.from(new Set((externalUserIds ?? []).map((id) => toSafeString(id, 128)).filter(Boolean)));
|
||||
if (requireSyncEnabled && !config?.wecomCheckinSyncEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_sync_disabled",
|
||||
message: "WECOM_CHECKIN_SYNC_ENABLED is not true",
|
||||
rules: [],
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
const checkinSecret = resolveCheckinSecret(config);
|
||||
if (!checkinSecret) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_secret_missing",
|
||||
message: "Missing WECOM_CHECKIN_SECRET or WECOM_AGENT_SECRET",
|
||||
rules: [],
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
if (!userIds.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_no_bound_users",
|
||||
message: null,
|
||||
rules: [],
|
||||
response: { userCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const tokenResult = await getWecomAppAccessToken({
|
||||
config: {
|
||||
...config,
|
||||
wecomAgentSecret: checkinSecret,
|
||||
},
|
||||
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
|
||||
fetchImpl,
|
||||
});
|
||||
if (!tokenResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: tokenResult.retryable,
|
||||
code: tokenResult.code,
|
||||
message: tokenResult.message,
|
||||
rules: [],
|
||||
response: tokenResult.response,
|
||||
};
|
||||
}
|
||||
|
||||
const dateValue = date instanceof Date ? date : buildShanghaiDateTime(date, false);
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckinoption");
|
||||
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
datetime: toUnixSeconds(dateValue),
|
||||
useridlist: userIds,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const sanitized = sanitizeCheckinApiResponse(payload);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: response.status >= 500 || response.status === 429,
|
||||
code: response.status >= 500 ? "wecom_checkin_rule_http_5xx" : "wecom_checkin_rule_http_error",
|
||||
message: `WeCom checkin rule HTTP ${response.status}`,
|
||||
rules: [],
|
||||
response: { status: response.status, body: sanitized },
|
||||
};
|
||||
}
|
||||
if (!payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_rule_api_error",
|
||||
message: `WeCom checkin rule errcode ${payload?.errcode ?? "missing_payload"}`,
|
||||
rules: [],
|
||||
response: sanitized,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_rule_fetched",
|
||||
message: null,
|
||||
rules: Array.isArray(payload.info) ? payload.info : [],
|
||||
response: sanitized,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchWecomCheckinRules,
|
||||
fetchWecomCheckinData,
|
||||
formatDateInTimezone,
|
||||
maskDelimitedIdentifiers,
|
||||
normalizeCheckinRecord,
|
||||
resolveCheckinSecret,
|
||||
sanitizeCheckinApiResponse,
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash("sha256").update(String(value ?? "")).digest("hex");
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function digestObject(value) {
|
||||
return sha256(stableJson(value));
|
||||
}
|
||||
|
||||
function maskExternalUserId(value) {
|
||||
const raw = toSafeString(value, 128);
|
||||
if (!raw) return null;
|
||||
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
|
||||
return `${raw.slice(0, visibleLength)}***`;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthOfDate(value) {
|
||||
return formatDateOnly(value).slice(0, 7);
|
||||
}
|
||||
|
||||
function toDateOnlyUtc(value) {
|
||||
const text = formatDateOnly(value);
|
||||
return text ? new Date(`${text}T00:00:00.000Z`) : null;
|
||||
}
|
||||
|
||||
function normalizeRecord(record) {
|
||||
const employeeId = toSafeString(record?.employee_id ?? record?.employeeId, 64);
|
||||
const externalUserId = toSafeString(record?.external_user_id ?? record?.externalUserId, 128);
|
||||
const checkinTime = record?.checkin_time ?? record?.checkinTime;
|
||||
const checkinDate = record?.checkin_date ?? record?.checkinDate ?? checkinTime;
|
||||
const checkinDateValue = toDateOnlyUtc(checkinDate);
|
||||
const checkinTimeValue = checkinTime instanceof Date ? checkinTime : new Date(checkinTime);
|
||||
if (!employeeId || !checkinDateValue || Number.isNaN(checkinTimeValue.getTime())) return null;
|
||||
return {
|
||||
employeeId,
|
||||
externalUserId,
|
||||
externalUserIdPrefix: maskExternalUserId(externalUserId),
|
||||
checkinDate: checkinDateValue,
|
||||
checkinDateString: formatDateOnly(checkinDateValue),
|
||||
checkinTime: checkinTimeValue,
|
||||
checkinType: toSafeString(record?.checkin_type ?? record?.checkinType ?? "unknown", 40) || "unknown",
|
||||
exceptionType: toSafeString(record?.exception_type ?? record?.exceptionType, 80),
|
||||
sourceType: toSafeString(record?.source_type ?? record?.sourceType ?? "unknown", 40) || "unknown",
|
||||
result: toSafeString(record?.result ?? "unknown", 80) || "unknown",
|
||||
rawHash: toSafeString(record?.raw_hash ?? record?.rawHash, 64),
|
||||
syncRunId: toSafeString(record?.sync_run_id ?? record?.syncRunId, 64),
|
||||
};
|
||||
}
|
||||
|
||||
function increment(map, key, amount = 1) {
|
||||
const normalized = toSafeString(key || "unknown", 80) || "unknown";
|
||||
map[normalized] = (map[normalized] || 0) + amount;
|
||||
}
|
||||
|
||||
function buildDailySummaries(records) {
|
||||
const groups = new Map();
|
||||
for (const raw of records || []) {
|
||||
const record = normalizeRecord(raw);
|
||||
if (!record) continue;
|
||||
const key = `${record.employeeId}|${record.checkinDateString}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
employee_id: record.employeeId,
|
||||
external_user_id_prefix: record.externalUserIdPrefix,
|
||||
checkin_date: record.checkinDate,
|
||||
record_count: 0,
|
||||
first_checkin_at: null,
|
||||
last_checkin_at: null,
|
||||
exception_count: 0,
|
||||
abnormal_count: 0,
|
||||
checkin_type_summary: {},
|
||||
result_summary: {},
|
||||
source_type_summary: {},
|
||||
rawHashes: [],
|
||||
source_record_count: 0,
|
||||
last_sync_run_id: record.syncRunId || null,
|
||||
});
|
||||
}
|
||||
const summary = groups.get(key);
|
||||
summary.record_count += 1;
|
||||
summary.source_record_count += 1;
|
||||
if (!summary.first_checkin_at || record.checkinTime < summary.first_checkin_at) {
|
||||
summary.first_checkin_at = record.checkinTime;
|
||||
}
|
||||
if (!summary.last_checkin_at || record.checkinTime > summary.last_checkin_at) {
|
||||
summary.last_checkin_at = record.checkinTime;
|
||||
}
|
||||
if (record.exceptionType) summary.exception_count += 1;
|
||||
if (record.exceptionType || !["normal", "regular", "success", "ok", "unknown", ""].includes(record.result.toLowerCase())) {
|
||||
summary.abnormal_count += 1;
|
||||
}
|
||||
increment(summary.checkin_type_summary, record.checkinType);
|
||||
increment(summary.result_summary, record.result);
|
||||
increment(summary.source_type_summary, record.sourceType);
|
||||
if (record.rawHash) summary.rawHashes.push(record.rawHash);
|
||||
if (record.syncRunId) summary.last_sync_run_id = record.syncRunId;
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).map((summary) => ({
|
||||
...summary,
|
||||
raw_hash_digest: summary.rawHashes.length ? digestObject(summary.rawHashes.sort()) : null,
|
||||
rawHashes: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildMonthlySummaries(dailySummaries, { expectedWorkdaysByMonth = {} } = {}) {
|
||||
const groups = new Map();
|
||||
for (const day of dailySummaries || []) {
|
||||
const month = monthOfDate(day.checkin_date);
|
||||
if (!month) continue;
|
||||
const key = `${day.employee_id}|${month}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
employee_id: day.employee_id,
|
||||
month,
|
||||
start_date: toDateOnlyUtc(`${month}-01`),
|
||||
end_date: toDateOnlyUtc(day.checkin_date),
|
||||
summary_day_count: 0,
|
||||
checkin_day_count: 0,
|
||||
exception_day_count: 0,
|
||||
abnormal_record_count: 0,
|
||||
missing_day_candidate_count: 0,
|
||||
first_checkin_at: null,
|
||||
last_checkin_at: null,
|
||||
result_summary: {},
|
||||
rawHashes: [],
|
||||
});
|
||||
}
|
||||
const summary = groups.get(key);
|
||||
summary.summary_day_count += 1;
|
||||
if (day.record_count > 0) summary.checkin_day_count += 1;
|
||||
if (day.exception_count > 0 || day.abnormal_count > 0) summary.exception_day_count += 1;
|
||||
summary.abnormal_record_count += Number(day.abnormal_count || 0);
|
||||
if (!summary.first_checkin_at || (day.first_checkin_at && day.first_checkin_at < summary.first_checkin_at)) {
|
||||
summary.first_checkin_at = day.first_checkin_at;
|
||||
}
|
||||
if (!summary.last_checkin_at || (day.last_checkin_at && day.last_checkin_at > summary.last_checkin_at)) {
|
||||
summary.last_checkin_at = day.last_checkin_at;
|
||||
}
|
||||
if (day.checkin_date > summary.end_date) summary.end_date = day.checkin_date;
|
||||
for (const [result, count] of Object.entries(day.result_summary || {})) {
|
||||
increment(summary.result_summary, result, count);
|
||||
}
|
||||
if (day.raw_hash_digest) summary.rawHashes.push(day.raw_hash_digest);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).map((summary) => {
|
||||
const expectedWorkdays = Number(expectedWorkdaysByMonth[summary.month] || 0);
|
||||
const missing = expectedWorkdays > 0 ? Math.max(0, expectedWorkdays - summary.checkin_day_count) : 0;
|
||||
return {
|
||||
...summary,
|
||||
missing_day_candidate_count: missing,
|
||||
raw_hash_digest: summary.rawHashes.length ? digestObject(summary.rawHashes.sort()) : null,
|
||||
rawHashes: undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertDailySummaries(prisma, summaries) {
|
||||
let count = 0;
|
||||
for (const summary of summaries || []) {
|
||||
await prisma.wecomCheckinDailySummary.upsert({
|
||||
where: {
|
||||
employee_id_checkin_date: {
|
||||
employee_id: summary.employee_id,
|
||||
checkin_date: summary.checkin_date,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
employee_id: summary.employee_id,
|
||||
external_user_id_prefix: summary.external_user_id_prefix,
|
||||
checkin_date: summary.checkin_date,
|
||||
record_count: summary.record_count,
|
||||
first_checkin_at: summary.first_checkin_at,
|
||||
last_checkin_at: summary.last_checkin_at,
|
||||
exception_count: summary.exception_count,
|
||||
abnormal_count: summary.abnormal_count,
|
||||
checkin_type_summary: summary.checkin_type_summary,
|
||||
result_summary: summary.result_summary,
|
||||
source_type_summary: summary.source_type_summary,
|
||||
raw_hash_digest: summary.raw_hash_digest,
|
||||
source_record_count: summary.source_record_count,
|
||||
last_sync_run_id: summary.last_sync_run_id,
|
||||
},
|
||||
update: {
|
||||
external_user_id_prefix: summary.external_user_id_prefix,
|
||||
record_count: summary.record_count,
|
||||
first_checkin_at: summary.first_checkin_at,
|
||||
last_checkin_at: summary.last_checkin_at,
|
||||
exception_count: summary.exception_count,
|
||||
abnormal_count: summary.abnormal_count,
|
||||
checkin_type_summary: summary.checkin_type_summary,
|
||||
result_summary: summary.result_summary,
|
||||
source_type_summary: summary.source_type_summary,
|
||||
raw_hash_digest: summary.raw_hash_digest,
|
||||
source_record_count: summary.source_record_count,
|
||||
last_sync_run_id: summary.last_sync_run_id,
|
||||
archived_at: new Date(),
|
||||
},
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function upsertMonthlySummaries(prisma, summaries) {
|
||||
let count = 0;
|
||||
for (const summary of summaries || []) {
|
||||
await prisma.wecomCheckinMonthlySummary.upsert({
|
||||
where: {
|
||||
employee_id_month: {
|
||||
employee_id: summary.employee_id,
|
||||
month: summary.month,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
employee_id: summary.employee_id,
|
||||
month: summary.month,
|
||||
start_date: summary.start_date,
|
||||
end_date: summary.end_date,
|
||||
summary_day_count: summary.summary_day_count,
|
||||
checkin_day_count: summary.checkin_day_count,
|
||||
exception_day_count: summary.exception_day_count,
|
||||
abnormal_record_count: summary.abnormal_record_count,
|
||||
missing_day_candidate_count: summary.missing_day_candidate_count,
|
||||
first_checkin_at: summary.first_checkin_at,
|
||||
last_checkin_at: summary.last_checkin_at,
|
||||
result_summary: summary.result_summary,
|
||||
raw_hash_digest: summary.raw_hash_digest,
|
||||
},
|
||||
update: {
|
||||
start_date: summary.start_date,
|
||||
end_date: summary.end_date,
|
||||
summary_day_count: summary.summary_day_count,
|
||||
checkin_day_count: summary.checkin_day_count,
|
||||
exception_day_count: summary.exception_day_count,
|
||||
abnormal_record_count: summary.abnormal_record_count,
|
||||
missing_day_candidate_count: summary.missing_day_candidate_count,
|
||||
first_checkin_at: summary.first_checkin_at,
|
||||
last_checkin_at: summary.last_checkin_at,
|
||||
result_summary: summary.result_summary,
|
||||
raw_hash_digest: summary.raw_hash_digest,
|
||||
archived_at: new Date(),
|
||||
},
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function summarizeRuleOption(rule) {
|
||||
const source = rule && typeof rule === "object" ? rule : {};
|
||||
const ruleKeys = Object.keys(source).filter((key) => !/loc|lat|lng|face|device|mac|wifi|payload/i.test(key)).sort();
|
||||
const group = source.group || source.groupname || source.group_name || {};
|
||||
const checkinDate = source.checkin_date || source.date || source.datetime || null;
|
||||
return {
|
||||
keyCount: ruleKeys.length,
|
||||
keySummary: ruleKeys.slice(0, 20),
|
||||
hasGroup: Boolean(group && Object.keys(group).length),
|
||||
hasScheduleList: Array.isArray(source.checkin_time) || Array.isArray(source.checkinTime) || Array.isArray(source.sch_checkin_time),
|
||||
hasSpecialDays: Boolean(source.spe_workdays || source.spe_offdays || source.speWorkdays || source.speOffdays),
|
||||
datePresent: Boolean(checkinDate),
|
||||
};
|
||||
}
|
||||
|
||||
function extractRuleUserId(rule) {
|
||||
return toSafeString(rule?.userid ?? rule?.UserId ?? rule?.user_id ?? rule?.open_userid, 128);
|
||||
}
|
||||
|
||||
function buildRuleSnapshots({ rules, employeeByExternalUserId, snapshotDate }) {
|
||||
const dateValue = toDateOnlyUtc(snapshotDate || new Date());
|
||||
return (rules || []).map((rule) => {
|
||||
const externalUserId = extractRuleUserId(rule);
|
||||
const employee = employeeByExternalUserId?.get(externalUserId);
|
||||
const ruleSummary = summarizeRuleOption(rule);
|
||||
return {
|
||||
employee_id: employee?.id || null,
|
||||
external_user_id_prefix: maskExternalUserId(externalUserId),
|
||||
snapshot_date: dateValue,
|
||||
rule_hash: digestObject(ruleSummary),
|
||||
rule_summary: ruleSummary,
|
||||
};
|
||||
}).filter((item) => item.employee_id && item.snapshot_date && item.rule_hash);
|
||||
}
|
||||
|
||||
async function upsertRuleSnapshots(prisma, snapshots) {
|
||||
let count = 0;
|
||||
for (const snapshot of snapshots || []) {
|
||||
await prisma.wecomCheckinRuleSnapshot.upsert({
|
||||
where: {
|
||||
employee_id_snapshot_date_rule_hash: {
|
||||
employee_id: snapshot.employee_id,
|
||||
snapshot_date: snapshot.snapshot_date,
|
||||
rule_hash: snapshot.rule_hash,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
employee_id: snapshot.employee_id,
|
||||
external_user_id_prefix: snapshot.external_user_id_prefix,
|
||||
snapshot_date: snapshot.snapshot_date,
|
||||
rule_hash: snapshot.rule_hash,
|
||||
rule_summary: snapshot.rule_summary,
|
||||
},
|
||||
update: {
|
||||
external_user_id_prefix: snapshot.external_user_id_prefix,
|
||||
rule_summary: snapshot.rule_summary,
|
||||
archived_at: new Date(),
|
||||
},
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
async function archiveCheckinSummaries({ prisma, records, ruleSnapshots = [] }) {
|
||||
const dailySummaries = buildDailySummaries(records);
|
||||
const monthlySummaries = buildMonthlySummaries(dailySummaries);
|
||||
const dailyCount = await upsertDailySummaries(prisma, dailySummaries);
|
||||
const monthlyCount = await upsertMonthlySummaries(prisma, monthlySummaries);
|
||||
const ruleSnapshotCount = await upsertRuleSnapshots(prisma, ruleSnapshots);
|
||||
return {
|
||||
dailyCount,
|
||||
monthlyCount,
|
||||
ruleSnapshotCount,
|
||||
dailySummaries,
|
||||
monthlySummaries,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCheckinArchiveSnapshot(prisma, { startDate, endDate, employeeIds = [], limit = 50 } = {}) {
|
||||
const where = {};
|
||||
if (startDate || endDate) {
|
||||
where.checkin_date = {
|
||||
...(startDate ? { gte: toDateOnlyUtc(startDate) } : {}),
|
||||
...(endDate ? { lte: toDateOnlyUtc(endDate) } : {}),
|
||||
};
|
||||
}
|
||||
if (employeeIds.length) {
|
||||
where.employee_id = { in: employeeIds };
|
||||
}
|
||||
const [daily, monthly, rules] = await Promise.all([
|
||||
prisma.wecomCheckinDailySummary.findMany({
|
||||
where,
|
||||
orderBy: [{ checkin_date: "desc" }, { updated_at: "desc" }],
|
||||
take: limit,
|
||||
}),
|
||||
prisma.wecomCheckinMonthlySummary.findMany({
|
||||
where: employeeIds.length ? { employee_id: { in: employeeIds } } : {},
|
||||
orderBy: [{ month: "desc" }, { updated_at: "desc" }],
|
||||
take: limit,
|
||||
}),
|
||||
prisma.wecomCheckinRuleSnapshot.findMany({
|
||||
where: employeeIds.length ? { employee_id: { in: employeeIds } } : {},
|
||||
orderBy: [{ snapshot_date: "desc" }, { updated_at: "desc" }],
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
return { daily, monthly, rules };
|
||||
}
|
||||
|
||||
function sanitizeDailySummary(row) {
|
||||
return {
|
||||
employeeId: row.employee_id,
|
||||
userIdPrefix: row.external_user_id_prefix,
|
||||
checkinDate: formatDateOnly(row.checkin_date),
|
||||
recordCount: row.record_count,
|
||||
firstCheckinAt: row.first_checkin_at ? new Date(row.first_checkin_at).toISOString() : null,
|
||||
lastCheckinAt: row.last_checkin_at ? new Date(row.last_checkin_at).toISOString() : null,
|
||||
exceptionCount: row.exception_count,
|
||||
abnormalCount: row.abnormal_count,
|
||||
resultSummary: row.result_summary || {},
|
||||
rawHashDigestPrefix: row.raw_hash_digest ? `${row.raw_hash_digest.slice(0, 12)}...` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeMonthlySummary(row) {
|
||||
return {
|
||||
employeeId: row.employee_id,
|
||||
month: row.month,
|
||||
summaryDayCount: row.summary_day_count,
|
||||
checkinDayCount: row.checkin_day_count,
|
||||
exceptionDayCount: row.exception_day_count,
|
||||
abnormalRecordCount: row.abnormal_record_count,
|
||||
missingDayCandidateCount: row.missing_day_candidate_count,
|
||||
firstCheckinAt: row.first_checkin_at ? new Date(row.first_checkin_at).toISOString() : null,
|
||||
lastCheckinAt: row.last_checkin_at ? new Date(row.last_checkin_at).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeRuleSnapshot(row) {
|
||||
return {
|
||||
employeeId: row.employee_id,
|
||||
userIdPrefix: row.external_user_id_prefix,
|
||||
snapshotDate: formatDateOnly(row.snapshot_date),
|
||||
ruleHashPrefix: row.rule_hash ? `${row.rule_hash.slice(0, 12)}...` : null,
|
||||
ruleSummary: row.rule_summary || {},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
archiveCheckinSummaries,
|
||||
buildDailySummaries,
|
||||
buildMonthlySummaries,
|
||||
buildRuleSnapshots,
|
||||
digestObject,
|
||||
maskExternalUserId,
|
||||
readCheckinArchiveSnapshot,
|
||||
sanitizeDailySummary,
|
||||
sanitizeMonthlySummary,
|
||||
sanitizeRuleSnapshot,
|
||||
summarizeRuleOption,
|
||||
upsertDailySummaries,
|
||||
upsertMonthlySummaries,
|
||||
upsertRuleSnapshots,
|
||||
};
|
||||
@@ -86,6 +86,7 @@ describe("check-wecom-approval-fixtures", () => {
|
||||
live: false,
|
||||
applicantEmployeeNo: "SMOKE002",
|
||||
approverEmployeeNo: "SMOKE101",
|
||||
targets: ["leave", "missed_punch"],
|
||||
format: "json",
|
||||
});
|
||||
const live = parseArgs([
|
||||
@@ -105,6 +106,28 @@ describe("check-wecom-approval-fixtures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("supports WEXT1 outing and overtime dry-run targets without changing defaults", async () => {
|
||||
const options = parseArgs(["--format=json", "--target=outing", "--targets=overtime"]);
|
||||
expect(options.targets).toEqual(["outing", "overtime"]);
|
||||
|
||||
const createTemplateImpl = vi.fn();
|
||||
const applyEventImpl = vi.fn();
|
||||
const report = await executeApprovalFixtures(options, {
|
||||
config: baseConfig(),
|
||||
env: {},
|
||||
actorRows: actorRows(),
|
||||
createTemplateImpl,
|
||||
applyEventImpl,
|
||||
generatedAt: "2026-05-20T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_FIXTURE_DRY_RUN_READY");
|
||||
expect(report.scope.targets).toEqual(["outing", "overtime"]);
|
||||
expect(report.checks.fixturePayloads.map((item) => item.target)).toEqual(["outing", "overtime"]);
|
||||
expect(createTemplateImpl).not.toHaveBeenCalled();
|
||||
expect(applyEventImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes dry-run without calling WeCom write APIs", async () => {
|
||||
const createTemplateImpl = vi.fn();
|
||||
const applyEventImpl = vi.fn();
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
const {
|
||||
buildSecretCheck,
|
||||
buildWriteGate,
|
||||
executeCheckinSummaryArchive,
|
||||
parseArgs,
|
||||
} = require("../../../src/scripts/check-wecom-checkin-summary-archive");
|
||||
const {
|
||||
sanitizeCheckinApiResponse,
|
||||
} = require("../../../src/services/integrations/wecomCheckin");
|
||||
|
||||
const SENSITIVE_USER_ID = ["checkin", "summary", "full", "userid", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_AGENT_SECRET = ["checkin", "agent", "secret", "should", "not", "leak"].join("-");
|
||||
const SENSITIVE_CHECKIN_SECRET = ["checkin", "override", "secret", "should", "not", "leak"].join("-");
|
||||
|
||||
function baseConfig(overrides = {}) {
|
||||
return {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
wecomCorpId: "corp",
|
||||
wecomAgentId: "1000002",
|
||||
wecomAgentSecret: SENSITIVE_AGENT_SECRET,
|
||||
wecomCheckinSecret: "",
|
||||
wecomRequestTimeoutMs: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function employeeRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
id: "employee-1",
|
||||
employee_no: "SMOKE002",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
wecom_identity_binding: { external_user_id: SENSITIVE_USER_ID },
|
||||
},
|
||||
{
|
||||
id: "employee-2",
|
||||
employee_no: "SMOKE101",
|
||||
status: "active",
|
||||
archived_at: null,
|
||||
wecom_identity_binding: { external_user_id: `${SENSITIVE_USER_ID}_approver` },
|
||||
},
|
||||
].map((row) => ({ ...row, ...(overrides[row.employee_no] || {}) }));
|
||||
}
|
||||
|
||||
function prismaStub() {
|
||||
const dailyRows = [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
record_count: 1,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
exception_count: 0,
|
||||
abnormal_count: 0,
|
||||
result_summary: { normal: 1 },
|
||||
raw_hash_digest: "a".repeat(64),
|
||||
},
|
||||
];
|
||||
return {
|
||||
employee: { findMany: vi.fn(async () => employeeRows()) },
|
||||
wecomCheckinSyncRun: {
|
||||
create: vi.fn(async () => ({ id: "sync-run-1" })),
|
||||
update: vi.fn(async () => ({ id: "sync-run-1" })),
|
||||
},
|
||||
wecomCheckinRecord: {
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
sync_run_id: "sync-run-1",
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
exception_type: null,
|
||||
source_type: "app",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
]),
|
||||
},
|
||||
wecomCheckinDailySummary: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => dailyRows),
|
||||
},
|
||||
wecomCheckinMonthlySummary: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
month: "2026-05",
|
||||
summary_day_count: 1,
|
||||
checkin_day_count: 1,
|
||||
exception_day_count: 0,
|
||||
abnormal_record_count: 0,
|
||||
missing_day_candidate_count: 0,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
wecomCheckinRuleSnapshot: {
|
||||
upsert: vi.fn(async () => ({})),
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
snapshot_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
rule_hash: "b".repeat(64),
|
||||
rule_summary: { keyCount: 2 },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("check-wecom-checkin-summary-archive", () => {
|
||||
it("parses defaults and write gate", () => {
|
||||
const options = parseArgs(["--live", "--persist-to-db", "--employee-no=SMOKE002", "--format=json"]);
|
||||
expect(options.live).toBe(true);
|
||||
expect(options.persistToDb).toBe(true);
|
||||
expect(options.employeeNos).toEqual(["SMOKE002"]);
|
||||
expect(buildWriteGate(options, {})).toMatchObject({
|
||||
status: "NEEDS_CONFIG",
|
||||
missing: ["WECOM_CHECKIN_ARCHIVE_WRITE"],
|
||||
});
|
||||
expect(buildWriteGate(options, { WECOM_CHECKIN_ARCHIVE_WRITE: "1" })).toMatchObject({
|
||||
status: "OK",
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("documents fixed Secret source with explicit checkin override", () => {
|
||||
expect(buildSecretCheck(baseConfig())).toMatchObject({
|
||||
source: "WECOM_AGENT_SECRET",
|
||||
configured: true,
|
||||
});
|
||||
expect(buildSecretCheck(baseConfig({ wecomCheckinSecret: SENSITIVE_CHECKIN_SECRET }))).toMatchObject({
|
||||
source: "WECOM_CHECKIN_SECRET",
|
||||
configured: true,
|
||||
override: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads offline archive without WeCom API and redacts report", async () => {
|
||||
const prisma = prismaStub();
|
||||
const report = await executeCheckinSummaryArchive(parseArgs(["--offline", "--format=json"]), {
|
||||
prisma,
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_CHECKIN_ARCHIVE_OFFLINE_READY");
|
||||
expect(report.checks.offline.counts.daily).toBe(1);
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("live persists OA summaries only after write env", async () => {
|
||||
const prisma = prismaStub();
|
||||
const fetchCheckinDataImpl = vi.fn(async ({ requireSyncEnabled, externalUserIds }) => {
|
||||
expect(requireSyncEnabled).toBe(false);
|
||||
expect(externalUserIds).toContain(SENSITIVE_USER_ID);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_checkin_fetched",
|
||||
records: [
|
||||
{
|
||||
userid: SENSITIVE_USER_ID,
|
||||
checkin_time: 1779238800,
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
},
|
||||
],
|
||||
response: { errcode: 0, checkindataCount: 1 },
|
||||
};
|
||||
});
|
||||
const fetchCheckinRulesImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
code: "wecom_checkin_rule_fetched",
|
||||
rules: [{ userid: SENSITIVE_USER_ID, groupname: "默认", loc_info: "must-not-leak" }],
|
||||
response: { errcode: 0, ruleInfoCount: 1 },
|
||||
}));
|
||||
|
||||
const report = await executeCheckinSummaryArchive(parseArgs([
|
||||
"--live",
|
||||
"--persist-to-db",
|
||||
"--format=json",
|
||||
"--start-date=2026-05-20",
|
||||
"--end-date=2026-05-20",
|
||||
]), {
|
||||
prisma,
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
env: { WECOM_CHECKIN_ARCHIVE_WRITE: "1" },
|
||||
fetchCheckinDataImpl,
|
||||
fetchCheckinRulesImpl,
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_CHECKIN_ARCHIVE_READY");
|
||||
expect(prisma.wecomCheckinRecord.createMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.wecomCheckinDailySummary.upsert).toHaveBeenCalled();
|
||||
expect(prisma.wecomCheckinRuleSnapshot.upsert).toHaveBeenCalled();
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).not.toContain(SENSITIVE_AGENT_SECRET);
|
||||
expect(text).not.toContain("must-not-leak");
|
||||
});
|
||||
|
||||
it("classifies API errors as external config blockers", async () => {
|
||||
const shortSensitiveUserId = "SMOKE002";
|
||||
const report = await executeCheckinSummaryArchive(parseArgs(["--live", "--format=json"]), {
|
||||
prisma: prismaStub(),
|
||||
config: baseConfig(),
|
||||
employees: employeeRows(),
|
||||
fetchCheckinDataImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
code: "wecom_checkin_api_error",
|
||||
message: "WeCom checkin errcode 301062",
|
||||
records: [],
|
||||
response: { errcode: 301062, invaliduser: "SM***" },
|
||||
})),
|
||||
fetchCheckinRulesImpl: vi.fn(),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(JSON.stringify(report.checks.live.api.response)).not.toContain(shortSensitiveUserId);
|
||||
});
|
||||
|
||||
it("redacts invaliduser values from checkin API responses", () => {
|
||||
const sanitized = sanitizeCheckinApiResponse({
|
||||
errcode: 301062,
|
||||
errmsg: "invalid user",
|
||||
invaliduser: "SMOKE002|SMOKE101",
|
||||
});
|
||||
|
||||
expect(sanitized.invaliduser).toBe("SM***|SM***");
|
||||
expect(JSON.stringify(sanitized)).not.toContain("SMOKE002");
|
||||
expect(JSON.stringify(sanitized)).not.toContain("SMOKE101");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
const {
|
||||
assertAlertSendAllowed,
|
||||
buildAlertMessage,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
shouldSendForSummary,
|
||||
} = require("../../../src/scripts/send-wecom-ops-alert");
|
||||
|
||||
const SENSITIVE_USER_ID = ["ops", "alert", "full", "userid", "should", "not", "leak"].join("_");
|
||||
const SENSITIVE_TOKEN = ["ops", "alert", "token", "should", "not", "leak"].join("-");
|
||||
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
summaryFile: "",
|
||||
recipientEmployeeNos: ["wangshunli", "HYD000006"],
|
||||
send: false,
|
||||
force: false,
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-ops-alert-test",
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseSummary(overrides = {}) {
|
||||
return {
|
||||
issueId: "WARCH3",
|
||||
status: "PASS_WITH_FINDINGS",
|
||||
findingCount: 1,
|
||||
reportDir: "/app/artifacts/qa-flow/warch3-ops-test",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function recipientRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
employeeNo: "wangshunli",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
},
|
||||
{
|
||||
employeeNo: "HYD000006",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
},
|
||||
].map((row) => ({ ...row, ...(overrides[row.employeeNo] || {}) }));
|
||||
}
|
||||
|
||||
describe("send-wecom-ops-alert", () => {
|
||||
it("parses recipients and enforces double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--summary-file=/tmp/summary.json",
|
||||
"--recipient-employee-no=wangshunli",
|
||||
"--recipient-employee-no",
|
||||
"HYD000006",
|
||||
"--send",
|
||||
"--format=json",
|
||||
]);
|
||||
|
||||
expect(options.summaryFile).toBe("/tmp/summary.json");
|
||||
expect(options.recipientEmployeeNos).toEqual(["wangshunli", "HYD000006"]);
|
||||
expect(options.send).toBe(true);
|
||||
expect(() => assertAlertSendAllowed(options, {})).toThrow(/WARCH3_OPS_ALERT_SEND=1/);
|
||||
expect(() => assertAlertSendAllowed(options, { WARCH3_OPS_ALERT_SEND: "1" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("builds a dehydrated message from summary only", () => {
|
||||
const message = buildAlertMessage(baseSummary({
|
||||
reportDir: `/app/${SENSITIVE_TOKEN}/summary`,
|
||||
}));
|
||||
expect(message.title).toContain("PASS_WITH_FINDINGS");
|
||||
expect(message.body).toContain("阶段:WARCH3");
|
||||
expect(message.body).toContain("finding:1");
|
||||
expect(message.body).not.toContain("手机号");
|
||||
});
|
||||
|
||||
it("keeps PASS without findings as no-send unless forced", () => {
|
||||
const summary = baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0 });
|
||||
expect(shouldSendForSummary(summary, baseOptions())).toBe(false);
|
||||
expect(shouldSendForSummary(summary, baseOptions({ force: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("dry-runs alert without sending and redacts recipients", async () => {
|
||||
const sendMessageImpl = vi.fn();
|
||||
const report = await executeOpsAlert(baseOptions(), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl,
|
||||
env: {},
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_DRY_RUN_READY");
|
||||
expect(report.delivery.status).toBe("DRY_RUN_READY");
|
||||
expect(sendMessageImpl).not.toHaveBeenCalled();
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).toContain("ops_***");
|
||||
});
|
||||
|
||||
it("sends only after confirmation and does not leak full userid", async () => {
|
||||
const sendMessageImpl = vi.fn(async ({ recipientExternalUserIds, message }) => {
|
||||
expect(recipientExternalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
expect(message.body).toContain("报告:/app/artifacts/qa-flow/warch3-ops-test");
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
message: null,
|
||||
response: { errcode: 0, recipientCount: 1 },
|
||||
};
|
||||
});
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT");
|
||||
expect(report.delivery.status).toBe("OK");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("sends to the valid admin recipient while recording alias gaps", async () => {
|
||||
const sendMessageImpl = vi.fn(async ({ recipientExternalUserIds }) => {
|
||||
expect(recipientExternalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
message: null,
|
||||
response: { errcode: 0, recipientCount: 1 },
|
||||
};
|
||||
});
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { employeeNo: "missing", wecomExternalUserId: "" },
|
||||
}).filter((row) => row.employeeNo !== "missing"),
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT_WITH_CONFIG_GAPS");
|
||||
expect(report.recipientGaps.map((item) => item.id)).toContain("recipient_missing");
|
||||
expect(report.delivery.status).toBe("OK");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("classifies missing binding as config gap", async () => {
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { wecomExternalUserId: "" },
|
||||
HYD000006: { wecomExternalUserId: "" },
|
||||
}),
|
||||
sendMessageImpl: vi.fn(),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_ALERT_NEEDS_CONFIG");
|
||||
expect(report.recipientGaps.map((item) => item.id)).toContain("recipient_missing_wecom_binding");
|
||||
});
|
||||
|
||||
it("classifies WeCom API error as external config blocker", async () => {
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl: vi.fn(async () => ({
|
||||
ok: false,
|
||||
code: "wecom_app_api_error",
|
||||
message: "WeCom app message errcode 48002",
|
||||
response: { errcode: 48002, invaliduser: SENSITIVE_USER_ID },
|
||||
})),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
expect(report.conclusion).toBe("BLOCKED_BY_EXTERNAL_CONFIG");
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(JSON.stringify(report.delivery.response)).toContain("ops_***");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const {
|
||||
buildDailySummaries,
|
||||
buildMonthlySummaries,
|
||||
buildRuleSnapshots,
|
||||
maskExternalUserId,
|
||||
sanitizeDailySummary,
|
||||
summarizeRuleOption,
|
||||
} = require("../../../src/services/integrations/wecomCheckinArchive");
|
||||
|
||||
const SENSITIVE_USER_ID = ["checkin", "archive", "full", "userid", "should", "not", "leak"].join("_");
|
||||
|
||||
describe("wecomCheckinArchive", () => {
|
||||
it("builds daily summaries from normalized records without raw payload fields", () => {
|
||||
const summaries = buildDailySummaries([
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
exception_type: "",
|
||||
source_type: "app",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T10:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OffDuty",
|
||||
exception_type: "late",
|
||||
source_type: "app",
|
||||
result: "exception",
|
||||
raw_hash: "b".repeat(64),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]).toMatchObject({
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
record_count: 2,
|
||||
exception_count: 1,
|
||||
abnormal_count: 1,
|
||||
});
|
||||
expect(summaries[0].checkin_type_summary).toEqual({ OnDuty: 1, OffDuty: 1 });
|
||||
expect(JSON.stringify(summaries)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("builds monthly summaries from daily summaries", () => {
|
||||
const daily = buildDailySummaries([
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-19T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-19T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
raw_hash: "a".repeat(64),
|
||||
},
|
||||
{
|
||||
employee_id: "employee-1",
|
||||
external_user_id: SENSITIVE_USER_ID,
|
||||
checkin_time: new Date("2026-05-20T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
checkin_type: "OnDuty",
|
||||
result: "normal",
|
||||
raw_hash: "b".repeat(64),
|
||||
},
|
||||
]);
|
||||
const monthly = buildMonthlySummaries(daily, { expectedWorkdaysByMonth: { "2026-05": 3 } });
|
||||
|
||||
expect(monthly).toHaveLength(1);
|
||||
expect(monthly[0]).toMatchObject({
|
||||
employee_id: "employee-1",
|
||||
month: "2026-05",
|
||||
summary_day_count: 2,
|
||||
checkin_day_count: 2,
|
||||
missing_day_candidate_count: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes rule snapshots without location, face, device or raw payload", () => {
|
||||
const employeeByExternalUserId = new Map([[SENSITIVE_USER_ID, { id: "employee-1" }]]);
|
||||
const snapshots = buildRuleSnapshots({
|
||||
snapshotDate: "2026-05-20",
|
||||
employeeByExternalUserId,
|
||||
rules: [
|
||||
{
|
||||
userid: SENSITIVE_USER_ID,
|
||||
groupname: { text: "默认规则" },
|
||||
checkin_time: [{ work_sec: 32400 }],
|
||||
loc_infos: [{ lat: 30, lng: 120 }],
|
||||
deviceid: "sensitive-device",
|
||||
face: "sensitive-face",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(snapshots).toHaveLength(1);
|
||||
expect(snapshots[0].employee_id).toBe("employee-1");
|
||||
expect(snapshots[0].external_user_id_prefix).toBe(maskExternalUserId(SENSITIVE_USER_ID));
|
||||
const text = JSON.stringify(snapshots);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
expect(text).not.toContain("sensitive-device");
|
||||
expect(text).not.toContain("sensitive-face");
|
||||
expect(text).not.toContain("loc_infos");
|
||||
});
|
||||
|
||||
it("sanitizes daily rows for reports", () => {
|
||||
const sanitized = sanitizeDailySummary({
|
||||
employee_id: "employee-1",
|
||||
external_user_id_prefix: "chec***",
|
||||
checkin_date: new Date("2026-05-20T00:00:00.000Z"),
|
||||
record_count: 1,
|
||||
first_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
last_checkin_at: new Date("2026-05-20T01:00:00.000Z"),
|
||||
exception_count: 0,
|
||||
abnormal_count: 0,
|
||||
result_summary: { normal: 1 },
|
||||
raw_hash_digest: "a".repeat(64),
|
||||
});
|
||||
|
||||
expect(sanitized).toMatchObject({
|
||||
employeeId: "employee-1",
|
||||
userIdPrefix: "chec***",
|
||||
checkinDate: "2026-05-20",
|
||||
rawHashDigestPrefix: "aaaaaaaaaaaa...",
|
||||
});
|
||||
});
|
||||
|
||||
it("masks short external user ids without exposing the full value", () => {
|
||||
expect(maskExternalUserId("ABCD")).toBe("A***");
|
||||
expect(maskExternalUserId("SMOKE002")).toBe("SM***");
|
||||
expect(maskExternalUserId("SMOKE002X")).toBe("SMOK***");
|
||||
});
|
||||
|
||||
it("filters sensitive rule keys from summaries", () => {
|
||||
const summary = summarizeRuleOption({
|
||||
userid: SENSITIVE_USER_ID,
|
||||
loc_info: "must-not-appear",
|
||||
device: "must-not-appear",
|
||||
face: "must-not-appear",
|
||||
groupname: "默认规则",
|
||||
});
|
||||
const text = JSON.stringify(summary);
|
||||
expect(text).not.toContain("loc_info");
|
||||
expect(text).not.toContain("device");
|
||||
expect(text).not.toContain("face");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户