WARCH3/WCHECK1/WEXT1 完成受控上线后续自动化
Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
@@ -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");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户