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

Issue: WARCH3, WCHECK1, WEXT1
这个提交包含在:
2026-05-20 13:58:40 +08:00
父节点 512a58d40e
当前提交 56b720b8ab
共修改 28 个文件,包含 2909 行新增和 72 行删除
@@ -20,6 +20,14 @@ const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow"
const DEFAULT_APPLICANT_EMPLOYEE_NO = "SMOKE002";
const DEFAULT_APPROVER_EMPLOYEE_NO = "SMOKE101";
const WRITE_CONFIRM_ENV = "WECOM_APPROVAL_FIXTURE_WRITE";
const EXTENDED_APPROVAL_TARGETS = Object.freeze([
{ key: "outing", label: "外出" },
{ key: "overtime", label: "加班" },
]);
const ALL_APPROVAL_FIXTURE_TARGETS = Object.freeze([
...APPROVAL_POC_TARGETS,
...EXTENDED_APPROVAL_TARGETS,
]);
const REPORT_PRIVACY =
"approval fixture counts/masked userid/template_id/sp_no prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload";
@@ -36,6 +44,8 @@ Options:
--submit-test-approvals live 模式下允许提交低风险测试审批单。
--applicant-employee-no=<no> 申请人员工号,默认 SMOKE002。
--approver-employee-no=<no> 审批人员工号,默认 SMOKE101。
--target=<key> 样件类型,可重复;默认 leave + missed_punch。
--targets=<a,b> 样件类型列表;支持 leave, missed_punch, outing, overtime。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-fixtures-<timestamp>。
--help 显示帮助。
@@ -77,6 +87,7 @@ function parseArgs(argv) {
submitTestApprovals: false,
applicantEmployeeNo: DEFAULT_APPLICANT_EMPLOYEE_NO,
approverEmployeeNo: DEFAULT_APPROVER_EMPLOYEE_NO,
targets: [],
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-fixtures-${timestamp()}`),
help: false,
@@ -104,6 +115,16 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--approver-employee-no=")) {
options.approverEmployeeNo = toSafeString(arg.slice("--approver-employee-no=".length), 80);
} else if (arg === "--target") {
options.targets.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--target=")) {
options.targets.push(toSafeString(arg.slice("--target=".length), 40));
} else if (arg === "--targets") {
options.targets.push(...toSafeString(argv[index + 1], 200).split(","));
index += 1;
} else if (arg.startsWith("--targets=")) {
options.targets.push(...toSafeString(arg.slice("--targets=".length), 200).split(","));
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
@@ -128,6 +149,14 @@ function parseArgs(argv) {
if (!options.approverEmployeeNo) {
throw new Error("--approver-employee-no requires non-empty value");
}
const supportedTargets = new Set(ALL_APPROVAL_FIXTURE_TARGETS.map((item) => item.key));
const normalizedTargets = Array.from(new Set(options.targets.map((item) => toSafeString(item, 40)).filter(Boolean)));
for (const target of normalizedTargets) {
if (!supportedTargets.has(target)) {
throw new Error(`Unsupported --target: ${target}`);
}
}
options.targets = normalizedTargets.length ? normalizedTargets : APPROVAL_POC_TARGETS.map((item) => item.key);
return options;
}
@@ -136,7 +165,7 @@ function localized(text) {
}
function targetLabel(targetKey) {
return APPROVAL_POC_TARGETS.find((item) => item.key === targetKey)?.label || targetKey;
return ALL_APPROVAL_FIXTURE_TARGETS.find((item) => item.key === targetKey)?.label || targetKey;
}
function fixtureTimestamp(generatedAt) {
@@ -384,10 +413,13 @@ function buildActorPrecheck(rows, options) {
};
}
function buildFixturePlans(actorCheck, generatedAt) {
function buildFixturePlans(actorCheck, generatedAt, targetKeys = APPROVAL_POC_TARGETS.map((item) => item.key)) {
const applicantUserId = actorCheck.internalActors.applicant?.wecomExternalUserId || "";
const approverUserId = actorCheck.internalActors.approver?.wecomExternalUserId || "";
return APPROVAL_POC_TARGETS.map((target) => {
const targets = targetKeys
.map((key) => ALL_APPROVAL_FIXTURE_TARGETS.find((item) => item.key === key))
.filter(Boolean);
return targets.map((target) => {
const templatePayload = buildTemplateFixturePayload(target, generatedAt);
const applyPayload = buildApplyFixturePayload({
target,
@@ -505,6 +537,16 @@ async function executeAutoRerun({ liveWrite, executeApprovalPocImpl, generatedAt
wouldRun: "check:wecom-approval-poc",
};
}
const pocTargets = new Set(APPROVAL_POC_TARGETS.map((item) => item.key));
const unsupportedTargets = templateEntries.map(([key]) => key).filter((key) => !pocTargets.has(key));
if (unsupportedTargets.length) {
return {
status: "SKIPPED",
reason: "auto_rerun_only_supports_leave_and_missed_punch",
unsupportedTargets,
wouldRun: "check:wecom-approval-poc",
};
}
const templates = Object.fromEntries(templateEntries);
const spNos = Object.fromEntries(spNoEntries);
@@ -646,7 +688,7 @@ async function executeApprovalFixtures(options, deps = {}) {
actorCheck.gaps.push({ id: "database_read_unavailable", employeeNo: null, message: actorReadError });
}
const fixturePlans = buildFixturePlans(actorCheck, generatedAt);
const fixturePlans = buildFixturePlans(actorCheck, generatedAt, options.targets);
const writeRequested = liveWriteRequested(options);
const writeAllowed = liveWriteAllowed(options, env);
let liveWrite = {
@@ -696,7 +738,7 @@ async function executeApprovalFixtures(options, deps = {}) {
privacy: REPORT_PRIVACY,
scope: {
issue: "WAPC2F",
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
targets: options.targets,
applicantEmployeeNo: options.applicantEmployeeNo,
approverEmployeeNo: options.approverEmployeeNo,
templateDepth: "minimal_generic",
@@ -782,6 +824,7 @@ if (require.main === module) {
module.exports = {
buildActorPrecheck,
ALL_APPROVAL_FIXTURE_TARGETS,
buildApplyFixturePayload,
buildSecretCheck,
buildTemplateFixturePayload,
@@ -0,0 +1,623 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { getConfig } = require("../config");
const { createPrismaClient } = require("../prismaClient");
const {
fetchWecomCheckinData,
fetchWecomCheckinRules,
formatDateInTimezone,
normalizeCheckinRecord,
resolveCheckinSecret,
} = require("../services/integrations/wecomCheckin");
const {
archiveCheckinSummaries,
buildRuleSnapshots,
maskExternalUserId,
readCheckinArchiveSnapshot,
sanitizeDailySummary,
sanitizeMonthlySummary,
sanitizeRuleSnapshot,
} = require("../services/integrations/wecomCheckinArchive");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_EMPLOYEE_NOS = Object.freeze(["SMOKE002", "SMOKE101"]);
const WRITE_CONFIRM_ENV = "WECOM_CHECKIN_ARCHIVE_WRITE";
const REPORT_PRIVACY =
"checkin counts/date/status/exception summaries/masked userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/location/face/device/raw payload";
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-checkin-summary-archive.js [options]
QA-only 企微打卡摘要留存 runner。
默认 offline:只读 OA DB 摘要,不调用企微 API,不写 OA/企微数据。
Options:
--live 调用企微打卡只读接口。
--offline 只读 OA DB 摘要,默认。
--persist-to-db 将规范化打卡记录、日/月摘要和规则快照写入 OA DB。
--employee-no=<no> 测试员工号,可重复;默认 SMOKE002 + SMOKE101。
--start-date=<YYYY-MM-DD> 起始日期,默认最近 1 天。
--end-date=<YYYY-MM-DD> 截止日期,默认今天。
--include-rules live 模式下读取打卡规则快照,默认开启。
--no-include-rules live 模式下不读取规则。
--limit=<1..200> 离线读取条数,默认 50。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-checkin-summary-archive-<timestamp>。
--help 显示帮助。
写 OA 留存必须同时满足:
1) --persist-to-db
2) ${WRITE_CONFIRM_ENV}=1
Secret 固定规则:
打卡读取:WECOM_AGENT_SECRET,只有显式 WECOM_CHECKIN_SECRET 时覆盖。
不使用 WECOM_CONTACT_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
隐私边界:
报告只输出计数、日期、状态、异常摘要和脱敏 userid 前缀。
不输出手机号、邮箱、token、secret、完整 userid、位置、人脸、设备明文或企微原始 payload。
`);
}
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function safeMessage(value) {
return toSafeString(value, 500)
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
.slice(0, 240);
}
function parseArgs(argv) {
const options = {
live: false,
offline: true,
persistToDb: false,
employeeNos: [],
startDate: "",
endDate: "",
includeRules: true,
limit: 50,
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-checkin-summary-archive-${timestamp()}`),
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--live") {
options.live = true;
options.offline = false;
} else if (arg === "--offline") {
options.offline = true;
options.live = false;
} else if (arg === "--persist-to-db") {
options.persistToDb = true;
} else if (arg === "--employee-no") {
options.employeeNos.push(toSafeString(argv[index + 1], 80));
index += 1;
} else if (arg.startsWith("--employee-no=")) {
options.employeeNos.push(toSafeString(arg.slice("--employee-no=".length), 80));
} else if (arg === "--start-date") {
options.startDate = toSafeString(argv[index + 1], 20);
index += 1;
} else if (arg.startsWith("--start-date=")) {
options.startDate = toSafeString(arg.slice("--start-date=".length), 20);
} else if (arg === "--end-date") {
options.endDate = toSafeString(argv[index + 1], 20);
index += 1;
} else if (arg.startsWith("--end-date=")) {
options.endDate = toSafeString(arg.slice("--end-date=".length), 20);
} else if (arg === "--include-rules") {
options.includeRules = true;
} else if (arg === "--no-include-rules") {
options.includeRules = false;
} else if (arg === "--limit") {
options.limit = Number(argv[index + 1] || options.limit);
index += 1;
} else if (arg.startsWith("--limit=")) {
options.limit = Number(arg.slice("--limit=".length));
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
} else if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length);
} else if (arg === "--report-dir") {
options.reportDir = argv[index + 1] || options.reportDir;
index += 1;
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 200) {
throw new Error("--limit must be between 1 and 200");
}
options.limit = Math.floor(options.limit);
options.employeeNos = Array.from(new Set((options.employeeNos.length ? options.employeeNos : DEFAULT_EMPLOYEE_NOS).filter(Boolean)));
return options;
}
function buildDateWindow(options, generatedAt, timeZone = "Asia/Shanghai") {
const end = options.endDate || formatDateInTimezone(new Date(generatedAt), timeZone);
const start = options.startDate || formatDateInTimezone(new Date(new Date(`${end}T00:00:00+08:00`).getTime() - 24 * 60 * 60 * 1000), timeZone);
return { startDate: start, endDate: end };
}
function buildSecretCheck(config) {
const source = config?.wecomCheckinSecret ? "WECOM_CHECKIN_SECRET" : "WECOM_AGENT_SECRET";
const configured = Boolean(resolveCheckinSecret(config));
return {
id: "checkin_archive_secret_matrix",
source,
endpoint: "POST /cgi-bin/checkin/getcheckindata and /cgi-bin/checkin/getcheckinoption",
configured,
override: source === "WECOM_CHECKIN_SECRET",
noFallback: ["WECOM_CONTACT_SECRET", "WECOM_HR_ASSISTANT_SECRET"],
missing: configured ? [] : ["WECOM_AGENT_SECRET", "WECOM_CHECKIN_SECRET"],
};
}
function buildWriteGate(options, env) {
if (!options.persistToDb) {
return {
id: "checkin_archive_write_gate",
status: "DRY_RUN",
enabled: false,
reason: "run_with_--persist-to-db_and_WECOM_CHECKIN_ARCHIVE_WRITE=1_to_write_OA_archive",
};
}
if (env[WRITE_CONFIRM_ENV] !== "1") {
return {
id: "checkin_archive_write_gate",
status: "NEEDS_CONFIG",
enabled: false,
missing: [WRITE_CONFIRM_ENV],
};
}
return {
id: "checkin_archive_write_gate",
status: "OK",
enabled: true,
target: "OA_DB_only",
};
}
function sanitizeEmployee(row) {
return {
employeeNo: row.employee_no,
status: row.status,
archived: Boolean(row.archived_at),
hasWecomBinding: Boolean(row.wecom_identity_binding?.external_user_id),
userIdPrefix: maskExternalUserId(row.wecom_identity_binding?.external_user_id),
};
}
async function loadEmployees(prisma, employeeNos) {
return prisma.employee.findMany({
where: {
employee_no: { in: employeeNos },
},
select: {
id: true,
employee_no: true,
status: true,
archived_at: true,
wecom_identity_binding: {
select: { external_user_id: true },
},
},
orderBy: { employee_no: "asc" },
});
}
function buildEmployeeCheck(rows, employeeNos) {
const byNo = new Map((rows || []).map((row) => [row.employee_no, row]));
const gaps = [];
for (const employeeNo of employeeNos) {
const row = byNo.get(employeeNo);
if (!row) {
gaps.push({ id: "employee_missing", employeeNo });
continue;
}
if (row.status !== "active" || row.archived_at) {
gaps.push({ id: "employee_inactive", employeeNo });
}
if (!row.wecom_identity_binding?.external_user_id) {
gaps.push({ id: "employee_missing_wecom_binding", employeeNo });
}
}
return {
id: "checkin_archive_employee_scope",
status: gaps.length ? "NEEDS_CONFIG" : "OK",
scope: employeeNos,
employees: rows.map(sanitizeEmployee),
gaps,
};
}
async function persistLiveRecords({ prisma, employees, normalizedRecords, window }) {
const syncRun = await prisma.wecomCheckinSyncRun.create({
data: {
start_date: new Date(`${window.startDate}T00:00:00.000Z`),
end_date: new Date(`${window.endDate}T00:00:00.000Z`),
status: "archive_running",
employee_count: employees.length,
record_count: 0,
},
});
if (normalizedRecords.length) {
await prisma.wecomCheckinRecord.createMany({
data: normalizedRecords.map((record) => ({
sync_run_id: syncRun.id,
employee_id: record.employeeId,
external_user_id: record.externalUserId,
checkin_time: record.checkinTime,
checkin_date: record.checkinDate,
checkin_type: record.checkinType,
exception_type: record.exceptionType,
source_type: record.sourceType,
result: record.result,
raw_hash: record.rawHash,
})),
skipDuplicates: true,
});
}
await prisma.wecomCheckinSyncRun.update({
where: { id: syncRun.id },
data: {
status: "archive_success",
record_count: normalizedRecords.length,
},
});
return syncRun;
}
async function loadRecordsForSummary({ prisma, employeeIds, window }) {
return prisma.wecomCheckinRecord.findMany({
where: {
employee_id: { in: employeeIds },
checkin_date: {
gte: new Date(`${window.startDate}T00:00:00.000Z`),
lte: new Date(`${window.endDate}T00:00:00.000Z`),
},
},
orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }],
});
}
async function runLiveCheck({
config,
prisma,
employees,
employeeCheck,
window,
options,
writeGate,
fetchCheckinDataImpl,
fetchCheckinRulesImpl,
}) {
if (employeeCheck.status !== "OK") {
return {
status: "NEEDS_CONFIG",
reason: "employee_scope_not_ready",
records: [],
ruleSnapshots: [],
api: null,
rulesApi: null,
persisted: null,
};
}
const externalUserIds = employees.map((row) => row.wecom_identity_binding?.external_user_id).filter(Boolean);
const employeeByExternalUserId = new Map(
employees.map((row) => [row.wecom_identity_binding?.external_user_id, { id: row.id, employeeNo: row.employee_no }]).filter(([key]) => key),
);
const api = await fetchCheckinDataImpl({
config,
startDate: window.startDate,
endDate: window.endDate,
externalUserIds,
requireSyncEnabled: false,
});
if (!api.ok) {
return {
status: "BLOCKED",
reason: api.code || "wecom_checkin_api_blocked",
message: api.message ? safeMessage(api.message) : null,
records: [],
ruleSnapshots: [],
api: {
code: api.code,
response: api.response,
},
rulesApi: null,
persisted: null,
};
}
const normalizedRecords = (api.records || [])
.map((raw) => normalizeCheckinRecord(raw, {
employeeByExternalUserId,
timeZone: config.appTimezone || "Asia/Shanghai",
}))
.filter(Boolean);
let rulesApi = null;
let ruleSnapshots = [];
if (options.includeRules) {
rulesApi = await fetchCheckinRulesImpl({
config,
date: window.endDate,
externalUserIds,
requireSyncEnabled: false,
});
if (rulesApi.ok) {
ruleSnapshots = buildRuleSnapshots({
rules: rulesApi.rules || [],
employeeByExternalUserId,
snapshotDate: window.endDate,
});
}
}
let persisted = {
enabled: false,
dailyCount: 0,
monthlyCount: 0,
ruleSnapshotCount: 0,
sourceRecordCount: normalizedRecords.length,
};
if (writeGate.enabled) {
const syncRun = await persistLiveRecords({ prisma, employees, normalizedRecords, window });
const persistedRecords = await loadRecordsForSummary({
prisma,
employeeIds: employees.map((row) => row.id),
window,
});
const archive = await archiveCheckinSummaries({
prisma,
records: persistedRecords,
ruleSnapshots,
});
persisted = {
enabled: true,
syncRunId: syncRun.id,
sourceRecordCount: persistedRecords.length,
dailyCount: archive.dailyCount,
monthlyCount: archive.monthlyCount,
ruleSnapshotCount: archive.ruleSnapshotCount,
};
}
return {
status: "OK",
reason: api.code,
records: normalizedRecords,
ruleSnapshots,
api: {
code: api.code,
recordCount: normalizedRecords.length,
response: api.response,
},
rulesApi: rulesApi ? {
code: rulesApi.code,
ok: Boolean(rulesApi.ok),
ruleCount: Array.isArray(rulesApi.rules) ? rulesApi.rules.length : 0,
response: rulesApi.response,
} : null,
persisted,
};
}
async function runOfflineCheck({ prisma, employees, window, options }) {
let snapshot;
try {
snapshot = await readCheckinArchiveSnapshot(prisma, {
startDate: window.startDate,
endDate: window.endDate,
employeeIds: employees.map((row) => row.id),
limit: options.limit,
});
} catch (error) {
return {
status: "ERROR",
reason: "database_read_unavailable",
message: safeMessage(error?.message || error),
counts: { daily: 0, monthly: 0, rules: 0 },
samples: { daily: [], monthly: [], rules: [] },
};
}
return {
status: snapshot.daily.length || snapshot.monthly.length || snapshot.rules.length
? "OK"
: "EMPTY",
counts: {
daily: snapshot.daily.length,
monthly: snapshot.monthly.length,
rules: snapshot.rules.length,
},
samples: {
daily: snapshot.daily.slice(0, 5).map(sanitizeDailySummary),
monthly: snapshot.monthly.slice(0, 5).map(sanitizeMonthlySummary),
rules: snapshot.rules.slice(0, 5).map(sanitizeRuleSnapshot),
},
};
}
function evaluateConclusion({ secretCheck, employeeCheck, writeGate, liveCheck, offlineCheck, options }) {
if (!secretCheck.configured && options.live) {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
}
if (liveCheck?.status === "BLOCKED") {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
}
if (offlineCheck?.status === "ERROR") {
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG" };
}
if (employeeCheck.status !== "OK" || writeGate.status === "NEEDS_CONFIG") {
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_CONFIG" };
}
if (options.offline && offlineCheck?.status === "EMPTY") {
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA" };
}
if (options.live && liveCheck?.status === "OK") {
return { status: "PASS_CHECKIN_ARCHIVE_READY", conclusion: "PASS_CHECKIN_ARCHIVE_READY" };
}
if (offlineCheck?.status === "OK") {
return { status: "PASS_CHECKIN_ARCHIVE_OFFLINE_READY", conclusion: "PASS_CHECKIN_ARCHIVE_OFFLINE_READY" };
}
return { status: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_CHECKIN_ARCHIVE_NEEDS_DATA" };
}
async function executeCheckinSummaryArchive(options, deps = {}) {
const env = deps.env || process.env;
const generatedAt = deps.generatedAt || new Date().toISOString();
const config = deps.config || getConfig();
const window = buildDateWindow(options, generatedAt, config.appTimezone || "Asia/Shanghai");
const secretCheck = buildSecretCheck(config);
const writeGate = buildWriteGate(options, env);
let ownedPrisma = null;
let ownedPool = null;
try {
let prisma = deps.prisma;
if (!prisma) {
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
let employees = deps.employees;
let employeeReadError = null;
if (!employees) {
try {
employees = await loadEmployees(prisma, options.employeeNos);
} catch (error) {
employeeReadError = safeMessage(error?.message || error);
employees = [];
}
}
const employeeCheck = employeeReadError
? {
id: "checkin_archive_employee_scope",
status: "NEEDS_CONFIG",
scope: options.employeeNos,
employees: [],
gaps: [{ id: "database_read_unavailable", message: employeeReadError }],
}
: buildEmployeeCheck(employees, options.employeeNos);
const liveCheck = options.live
? await runLiveCheck({
config,
prisma,
employees,
employeeCheck,
window,
options,
writeGate,
fetchCheckinDataImpl: deps.fetchCheckinDataImpl || fetchWecomCheckinData,
fetchCheckinRulesImpl: deps.fetchCheckinRulesImpl || fetchWecomCheckinRules,
})
: null;
const offlineCheck = await runOfflineCheck({ prisma, employees, window, options });
const conclusion = evaluateConclusion({ secretCheck, employeeCheck, writeGate, liveCheck, offlineCheck, options });
return {
issueId: "WCHECK1",
mode: "wecom-checkin-summary-archive",
generatedAt,
status: conclusion.status,
conclusion: conclusion.conclusion,
window,
scope: {
employeeNos: options.employeeNos,
live: options.live,
offline: options.offline || !options.live,
includeRules: options.includeRules,
writeBehavior: writeGate.enabled ? "OA DB archive write enabled by CLI and env" : "read-only/offline unless --persist-to-db and WECOM_CHECKIN_ARCHIVE_WRITE=1",
},
checks: {
secretMatrix: secretCheck,
employees: employeeCheck,
writeGate,
live: liveCheck ? {
status: liveCheck.status,
reason: liveCheck.reason,
message: liveCheck.message || null,
api: liveCheck.api,
rulesApi: liveCheck.rulesApi,
recordCount: liveCheck.records.length,
ruleSnapshotCount: liveCheck.ruleSnapshots.length,
persisted: liveCheck.persisted,
} : null,
offline: offlineCheck,
},
privacy: REPORT_PRIVACY,
};
} finally {
if (ownedPrisma) await ownedPrisma.$disconnect().catch(() => {});
if (ownedPool) await ownedPool.end().catch(() => {});
}
}
function writeReport(reportDir, report) {
fs.mkdirSync(reportDir, { recursive: true });
const filePath = path.join(reportDir, "summary.json");
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
return filePath;
}
function printText(report, reportPath) {
process.stdout.write(`[wecom-checkin-summary-archive] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-checkin-summary-archive] report=${reportPath}\n`);
process.stdout.write(`[wecom-checkin-summary-archive] privacy=${report.privacy}\n`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeCheckinSummaryArchive(options);
const reportPath = writeReport(options.reportDir, report);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
} else {
printText(report, reportPath);
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[wecom-checkin-summary-archive][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
buildDateWindow,
buildSecretCheck,
buildWriteGate,
executeCheckinSummaryArchive,
parseArgs,
};
+415
查看文件
@@ -0,0 +1,415 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { Pool } = require("pg");
const { getConfig } = require("../config");
const { sendWecomMessage } = require("../services/integrations/im/wecom");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_RECIPIENTS = Object.freeze(["wangshunli", "HYD000006"]);
const SEND_CONFIRM_ENV = "WARCH3_OPS_ALERT_SEND";
function printHelp() {
process.stdout.write(`Usage: node src/scripts/send-wecom-ops-alert.js [options]
QA-only WARCH3 企微巡检脱敏告警。
默认 dry-run:只生成预览和报告,不发送企微消息。
Options:
--summary-file=<path> WARCH3/WROAD2 summary.json。
--recipient-employee-no=<no> 收件员工号,可重复;默认 wangshunli + HYD000006。
--send 发送企微应用消息。
--force PASS 且无 finding 时也发送测试消息。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-ops-alert-<timestamp>。
--help 显示帮助。
发送必须同时满足:
1) --send
2) ${SEND_CONFIRM_ENV}=1
隐私边界:
消息和报告只包含阶段、状态、finding 数、报告路径和下一步建议。
不输出手机号、邮箱、token、Secret、完整 userid、完整 template_id、完整 sp_no、表单值、附件、评论或原始 payload。
`);
}
function timestamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
}
function toSafeString(value, maxLength = 500) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function safeMessage(value) {
return toSafeString(value, 500)
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.replace(/[A-Za-z0-9+/=_-]{24,}/g, "***")
.slice(0, 240);
}
function maskIdentifier(value) {
const raw = toSafeString(value, 160);
if (!raw) return null;
const visibleLength = raw.length <= 4 ? 1 : raw.length <= 8 ? 2 : 4;
return `${raw.slice(0, visibleLength)}***`;
}
function parseArgs(argv) {
const options = {
summaryFile: "",
recipientEmployeeNos: [],
send: false,
force: false,
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-ops-alert-${timestamp()}`),
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help" || arg === "-h") {
options.help = true;
} else if (arg === "--summary-file") {
options.summaryFile = toSafeString(argv[index + 1], 1000);
index += 1;
} else if (arg.startsWith("--summary-file=")) {
options.summaryFile = toSafeString(arg.slice("--summary-file=".length), 1000);
} else if (arg === "--recipient-employee-no") {
options.recipientEmployeeNos.push(toSafeString(argv[index + 1], 80));
index += 1;
} else if (arg.startsWith("--recipient-employee-no=")) {
options.recipientEmployeeNos.push(toSafeString(arg.slice("--recipient-employee-no=".length), 80));
} else if (arg === "--send") {
options.send = true;
} else if (arg === "--force") {
options.force = true;
} else if (arg === "--format") {
options.format = argv[index + 1] || options.format;
index += 1;
} else if (arg.startsWith("--format=")) {
options.format = arg.slice("--format=".length);
} else if (arg === "--report-dir") {
options.reportDir = argv[index + 1] || options.reportDir;
index += 1;
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
options.recipientEmployeeNos = Array.from(new Set(
(options.recipientEmployeeNos.length ? options.recipientEmployeeNos : DEFAULT_RECIPIENTS).filter(Boolean),
));
if (!options.recipientEmployeeNos.length) {
throw new Error("--recipient-employee-no requires non-empty value");
}
return options;
}
function assertAlertSendAllowed(options, env = process.env) {
if (!options.send) return;
if (env[SEND_CONFIRM_ENV] !== "1") {
throw new Error(`Sending WARCH3 ops alert requires ${SEND_CONFIRM_ENV}=1`);
}
}
function readSummary(summaryFile) {
if (!summaryFile) {
return {
status: "PARTIAL_ALERT_NEEDS_CONFIG",
issueId: "WARCH3",
findingCount: 0,
reportDir: null,
notes: "summary_file_missing",
};
}
const resolved = path.resolve(summaryFile);
const raw = fs.readFileSync(resolved, "utf8");
const parsed = JSON.parse(raw);
return {
...parsed,
reportDir: parsed.reportDir || path.dirname(resolved),
};
}
function shouldSendForSummary(summary, options) {
if (options.force) return true;
const status = toSafeString(summary?.status || summary?.conclusion, 80);
return status !== "PASS_ARCHIVE_OPS_READY" || Number(summary?.findingCount || 0) > 0;
}
function nextStepForStatus(summary) {
const status = toSafeString(summary?.status || summary?.conclusion, 80);
if (status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
return "检查 Secret、权限、可信 IP、可见范围或外部配置后复跑。";
}
if (Number(summary?.findingCount || 0) > 0 || status === "PASS_WITH_FINDINGS") {
return "保留 finding 审计,优先处理真实阻断;已知 live smoke/OCR 与历史 callback gap 不阻断归档主链路。";
}
return "继续按 07:30 每日巡检节奏观察。";
}
function buildAlertMessage(summary) {
const issueId = toSafeString(summary?.issueId || "WARCH3", 40) || "WARCH3";
const status = toSafeString(summary?.status || summary?.conclusion || "UNKNOWN", 80) || "UNKNOWN";
const findingCount = Number(summary?.findingCount || 0);
const reportPath = toSafeString(summary?.reportDir || summary?.sourceReports?.wroad2 || "N/A", 500);
const nextStep = nextStepForStatus(summary);
return {
title: `OA 巡检 ${status}`,
body: [
`阶段:${issueId}`,
`状态:${status}`,
`finding:${Number.isFinite(findingCount) ? findingCount : 0}`,
`报告:${reportPath}`,
`建议:${nextStep}`,
].join("\n"),
footer: "脱敏巡检消息,仅含状态和报告路径。",
};
}
async function queryRecipientRows(employeeNos) {
if (!process.env.DATABASE_URL) {
throw new Error("Missing DATABASE_URL");
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query("BEGIN READ ONLY");
const result = await client.query(`
SELECT
e.employee_no,
e.status::text,
(e.archived_at IS NOT NULL) AS archived,
w.external_user_id AS wecom_external_user_id
FROM employees e
LEFT JOIN wecom_identity_bindings w ON w.employee_id = e.id
WHERE e.employee_no = ANY($1::text[])
ORDER BY e.employee_no ASC
`, [employeeNos]);
await client.query("COMMIT");
return result.rows.map((row) => ({
employeeNo: toSafeString(row.employee_no, 80),
status: toSafeString(row.status, 40),
archived: Boolean(row.archived),
wecomExternalUserId: toSafeString(row.wecom_external_user_id, 160),
}));
} catch (error) {
try {
await client.query("ROLLBACK");
} catch {
// Preserve original error.
}
throw error;
} finally {
client.release();
await pool.end();
}
}
function sanitizeRecipients(rows) {
return (rows || []).map((row) => ({
employeeNo: row.employeeNo,
status: row.status,
archived: Boolean(row.archived),
hasWecomBinding: Boolean(row.wecomExternalUserId),
wecomUserIdPrefix: maskIdentifier(row.wecomExternalUserId),
}));
}
function collectRecipientGaps(rows, employeeNos) {
const byNo = new Map((rows || []).map((row) => [row.employeeNo, row]));
const gaps = [];
for (const employeeNo of employeeNos) {
const row = byNo.get(employeeNo);
if (!row) {
gaps.push({ id: "recipient_missing", employeeNo });
continue;
}
if (row.status !== "active" || row.archived) {
gaps.push({ id: "recipient_inactive", employeeNo });
}
if (!row.wecomExternalUserId) {
gaps.push({ id: "recipient_missing_wecom_binding", employeeNo });
}
}
return gaps;
}
function maskDelimitedIdentifiers(value) {
const raw = toSafeString(value, 1000);
if (!raw) return raw;
return raw
.split(/[|,;\s]+/)
.filter(Boolean)
.map((item) => maskIdentifier(item))
.join("|");
}
function sanitizeDeliveryResponse(response) {
if (!response || typeof response !== "object") return response ?? null;
const sanitized = Array.isArray(response) ? [] : {};
for (const [key, value] of Object.entries(response)) {
if (/invaliduser|userid|touser|user_id/i.test(key) && typeof value === "string") {
sanitized[key] = maskDelimitedIdentifiers(value);
} else if (value && typeof value === "object") {
sanitized[key] = sanitizeDeliveryResponse(value);
} else if (typeof value === "string") {
sanitized[key] = safeMessage(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
async function executeOpsAlert(options, deps = {}) {
const env = deps.env || process.env;
assertAlertSendAllowed(options, env);
const generatedAt = deps.generatedAt || new Date().toISOString();
const config = deps.config || getConfig();
const summary = deps.summary || readSummary(options.summaryFile);
const message = buildAlertMessage(summary);
const shouldSend = shouldSendForSummary(summary, options);
let recipientRows = deps.recipientRows;
let recipientReadError = null;
if (!recipientRows) {
try {
recipientRows = await (deps.queryRecipientRows || queryRecipientRows)(options.recipientEmployeeNos);
} catch (error) {
recipientReadError = safeMessage(error?.message || error);
recipientRows = [];
}
}
const recipientGaps = collectRecipientGaps(recipientRows, options.recipientEmployeeNos);
if (recipientReadError) {
recipientGaps.push({ id: "database_read_unavailable", message: recipientReadError });
}
const externalUserIds = Array.from(new Set(
(recipientRows || [])
.filter((row) => row.status === "active" && !row.archived && row.wecomExternalUserId)
.map((row) => row.wecomExternalUserId),
));
let delivery = {
status: shouldSend ? "DRY_RUN_READY" : "SKIPPED_NO_FINDINGS",
code: shouldSend ? "dry_run" : "no_finding_or_failure",
response: null,
recipientCount: externalUserIds.length,
};
if (shouldSend && options.send && externalUserIds.length > 0) {
const result = await (deps.sendMessageImpl || sendWecomMessage)({
config,
channel: { config_json: { mode: "wecom_app_message", timeoutMs: config.wecomRequestTimeoutMs ?? 5000 } },
message,
recipientExternalUserIds: externalUserIds,
});
delivery = {
status: result.ok ? "OK" : "BLOCKED",
code: result.code,
message: result.message ? safeMessage(result.message) : null,
response: sanitizeDeliveryResponse(result.response),
recipientCount: result.response?.recipientCount ?? externalUserIds.length,
};
}
let status = "PASS_ALERT_DRY_RUN_READY";
let conclusion = "PASS_ALERT_DRY_RUN_READY";
if (delivery.status === "BLOCKED") {
status = "SKIP";
conclusion = "BLOCKED_BY_EXTERNAL_CONFIG";
} else if (delivery.status === "OK" && recipientGaps.length) {
status = "PASS_ALERT_SENT_WITH_CONFIG_GAPS";
conclusion = "PASS_ALERT_SENT_WITH_CONFIG_GAPS";
} else if (delivery.status === "OK") {
status = "PASS_ALERT_SENT";
conclusion = "PASS_ALERT_SENT";
} else if (recipientGaps.length) {
status = "PARTIAL_ALERT_NEEDS_CONFIG";
conclusion = "PARTIAL_ALERT_NEEDS_CONFIG";
} else if (delivery.status === "SKIPPED_NO_FINDINGS") {
status = "PASS_ALERT_SKIPPED_NO_FINDINGS";
conclusion = "PASS_ALERT_SKIPPED_NO_FINDINGS";
}
return {
issueId: "WARCH3",
mode: "wecom-ops-alert",
generatedAt,
status,
conclusion,
sendRequested: Boolean(options.send),
sendAllowed: Boolean(options.send && env[SEND_CONFIRM_ENV] === "1"),
shouldSend,
summary: {
issueId: summary?.issueId || null,
status: summary?.status || summary?.conclusion || "UNKNOWN",
findingCount: Number(summary?.findingCount || 0),
reportDir: summary?.reportDir || null,
},
recipients: sanitizeRecipients(recipientRows),
recipientGaps,
delivery,
messagePreview: buildAlertMessage(summary),
privacy: "stage/status/finding count/report path/next step only; no phone/email/token/secret/full userid/full template_id/full sp_no/form values/attachments/comments/raw payload",
};
}
function writeReport(reportDir, report) {
fs.mkdirSync(reportDir, { recursive: true });
const filePath = path.join(reportDir, "summary.json");
fs.writeFileSync(filePath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
return filePath;
}
function printText(report, reportPath) {
process.stdout.write(`[wecom-ops-alert] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-ops-alert] report=${reportPath}\n`);
process.stdout.write(`[wecom-ops-alert] delivery=${report.delivery.status} code=${report.delivery.code}\n`);
if (report.recipientGaps.length) {
process.stdout.write(`[wecom-ops-alert] recipientGaps=${JSON.stringify(report.recipientGaps)}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeOpsAlert(options);
const reportPath = writeReport(options.reportDir, report);
if (options.format === "json") {
process.stdout.write(`${JSON.stringify({ ...report, reportPath }, null, 2)}\n`);
} else {
printText(report, reportPath);
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(`[wecom-ops-alert][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
assertAlertSendAllowed,
buildAlertMessage,
executeOpsAlert,
parseArgs,
sanitizeDeliveryResponse,
shouldSendForSummary,
};