WARCH3/WCHECK1/WEXT1 完成受控上线后续自动化

Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
2026-05-20 13:58:40 +08:00
父节点 512a58d40e
当前提交 56b720b8ab
共修改 28 个文件,包含 2909 行新增和 72 行删除
+2
查看文件
@@ -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,
};
+415
查看文件
@@ -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");
});
});