WARCH1 实现企微审批最小历史留存

这个提交包含在:
2026-05-20 00:22:27 +08:00
父节点 56a8e2bb0c
当前提交 5cd960cf90
共修改 28 个文件,包含 3081 行新增和 261 行删除
@@ -0,0 +1,430 @@
#!/usr/bin/env node
require("dotenv/config");
const fs = require("node:fs");
const path = require("node:path");
const { createPrismaClient } = require("../prismaClient");
const { maskApprovalIdentifier } = require("../services/integrations/wecomApproval");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
const DEFAULT_TARGETS = Object.freeze(["leave", "missed_punch"]);
const REPORT_PRIVACY =
"OA DB archive counts/status summaries/masked sp_no/template/userid prefixes only; no phone/email/token/secret/full userid/full template_id/full sp_no/reason text/attachments/comments/raw payload";
function printHelp() {
process.stdout.write(`Usage: node src/scripts/check-wecom-approval-archive.js [options]
QA-only 企业微信审批自建留存离线读取检查。
该脚本只读取 OA DB,不调用企业微信 API,不写 OA/企微数据。
Options:
--offline 明确以离线模式验证 OA 自建留存可读。
--format=text|json 输出格式,默认 text。
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
--template=leave 只检查请假留存。
--template=missed_punch 只检查补卡留存。
--status=<status> 按规范化状态过滤。
--limit=<1..100> 最大读取条数,默认 20。
--help 显示帮助。
隐私边界:
报告只输出计数、状态摘要、字段类型、脱敏 sp_no/template/userid 前缀和 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 parseArgs(argv) {
const options = {
offline: false,
format: "text",
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-approval-archive-${timestamp()}`),
templates: [],
status: "",
limit: 20,
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 === "--offline") {
options.offline = 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 if (arg === "--template") {
options.templates.push(toSafeString(argv[index + 1], 40));
index += 1;
} else if (arg.startsWith("--template=")) {
options.templates.push(toSafeString(arg.slice("--template=".length), 40));
} else if (arg === "--status") {
options.status = toSafeString(argv[index + 1], 40);
index += 1;
} else if (arg.startsWith("--status=")) {
options.status = toSafeString(arg.slice("--status=".length), 40);
} 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 {
throw new Error(`Unsupported argument: ${arg}`);
}
}
if (!["text", "json"].includes(options.format)) {
throw new Error(`Unsupported --format: ${options.format}`);
}
const uniqueTemplates = Array.from(new Set(options.templates.filter(Boolean)));
for (const template of uniqueTemplates) {
if (!DEFAULT_TARGETS.includes(template)) {
throw new Error(`Unsupported --template: ${template}`);
}
}
options.templates = uniqueTemplates.length ? uniqueTemplates : [...DEFAULT_TARGETS];
if (!Number.isFinite(options.limit) || options.limit < 1 || options.limit > 100) {
throw new Error("--limit must be between 1 and 100");
}
options.limit = Math.floor(options.limit);
return options;
}
function countBy(items, selector) {
const counts = {};
for (const item of items || []) {
const key = toSafeString(selector(item), 80) || "unknown";
counts[key] = (counts[key] || 0) + 1;
}
return counts;
}
function buildWhere(options) {
const where = {
template_type: { in: options.templates },
};
if (options.status) {
where.normalized_status = options.status;
}
return where;
}
function sanitizeArchiveRow(row) {
const participants = (row.participants || []).map((item) => ({
role: item.role,
nodeIndex: item.node_index,
userIdPrefix: maskApprovalIdentifier(item.external_user_id),
mappingStatus: item.mapping_status,
current: Boolean(item.is_current),
}));
const actions = (row.actions || []).map((item) => ({
actionType: item.action_type,
operatorPrefix: maskApprovalIdentifier(item.operator_external_user_id),
actionTime: item.action_time ? new Date(item.action_time).toISOString() : null,
nodeIndex: item.node_index,
source: item.source,
}));
const fieldSummaries = (row.field_summaries || []).map((item) => ({
fieldName: item.display_allowed ? item.field_name : null,
fieldType: item.field_type || "unknown",
sensitiveLevel: item.sensitive_level,
displayAllowed: Boolean(item.display_allowed),
}));
const findings = (row.reconcile_findings || []).map((item) => ({
findingType: item.finding_type,
status: item.status,
severity: item.severity,
source: item.source,
detectedAt: item.detected_at ? new Date(item.detected_at).toISOString() : null,
}));
return {
archiveId: row.id,
spNoPrefix: maskApprovalIdentifier(row.sp_no),
templateIdPrefix: maskApprovalIdentifier(row.template_id),
templateType: row.template_type,
normalizedStatus: row.normalized_status,
submittedAt: row.submitted_at ? new Date(row.submitted_at).toISOString() : null,
statusObservedAt: row.status_observed_at ? new Date(row.status_observed_at).toISOString() : null,
completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null,
lastSource: row.last_source,
readabilityStatus: row.readability_status,
singleTapApproveRisk: Boolean(row.single_tap_approve_risk),
templateAdjustmentRequired: Boolean(row.template_adjustment_required),
participantRoleCounts: countBy(participants, (item) => item.role),
actionTypeCounts: countBy(actions, (item) => item.actionType),
fieldTypeCounts: countBy(fieldSummaries, (item) => item.fieldType),
openFindingCounts: countBy(findings, (item) => item.findingType),
participants,
actions,
fieldSummaries,
findings,
counts: row._count || {},
};
}
async function readArchiveRows(prisma, options) {
return prisma.wecomApprovalArchive.findMany({
where: buildWhere(options),
orderBy: [
{ status_observed_at: "desc" },
{ updated_at: "desc" },
],
take: options.limit,
select: {
id: true,
sp_no: true,
template_id: true,
template_type: true,
normalized_status: true,
submitted_at: true,
completed_at: true,
status_observed_at: true,
last_source: true,
readability_status: true,
single_tap_approve_risk: true,
template_adjustment_required: true,
participants: {
take: 20,
orderBy: [{ role: "asc" }, { node_index: "asc" }],
select: {
role: true,
node_index: true,
external_user_id: true,
mapping_status: true,
is_current: true,
},
},
actions: {
take: 30,
orderBy: [{ action_time: "asc" }, { created_at: "asc" }],
select: {
action_type: true,
operator_external_user_id: true,
action_time: true,
node_index: true,
source: true,
},
},
field_summaries: {
take: 30,
orderBy: [{ field_index: "asc" }],
select: {
field_name: true,
field_type: true,
sensitive_level: true,
display_allowed: true,
},
},
reconcile_findings: {
where: { status: "open" },
take: 20,
orderBy: [{ detected_at: "desc" }],
select: {
finding_type: true,
status: true,
severity: true,
source: true,
detected_at: true,
},
},
_count: {
select: {
participants: true,
actions: true,
field_summaries: true,
attachment_summaries: true,
callback_events: true,
compensation_items: true,
reconcile_findings: true,
},
},
},
});
}
function evaluateArchiveReport({ options, archives, error, configGaps }) {
if (error) {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
}
if (configGaps.length || !archives.length) {
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
}
if (archives.some((item) => item.findings?.length)) {
return { status: "PASS_WITH_FINDINGS", conclusion: "PASS_WITH_FINDINGS" };
}
if (!options.offline) {
return { status: "PARTIAL_ARCHIVE_NEEDS_DATA", conclusion: "PARTIAL_ARCHIVE_NEEDS_DATA" };
}
return { status: "PASS_ARCHIVE_HISTORY_READABLE", conclusion: "PASS_ARCHIVE_HISTORY_READABLE" };
}
async function executeArchiveCheck(options, deps = {}) {
const generatedAt = deps.generatedAt || new Date().toISOString();
const env = deps.env || process.env;
const configGaps = [];
const mainBlockers = [];
let ownedPrisma = null;
let ownedPool = null;
let rawRows = [];
let readError = null;
if (!options.offline) {
configGaps.push({
id: "offline_mode",
status: "NEEDS_CONFIG",
reason: "run_with_--offline_to_prove_no_wecom_api_dependency",
});
}
try {
let prisma = deps.prisma;
if (!prisma) {
if (!env.DATABASE_URL) {
throw new Error("DATABASE_URL is required for OA archive offline read");
}
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
rawRows = await (deps.readArchiveRowsImpl || readArchiveRows)(prisma, options);
} catch (error) {
readError = error;
mainBlockers.push({
id: "archive_db_read",
reason: "oa_archive_db_read_failed",
message: safeMessage(error?.message || error),
});
} finally {
if (ownedPrisma?.$disconnect) {
await ownedPrisma.$disconnect().catch(() => null);
}
if (ownedPool?.end) {
await ownedPool.end().catch(() => null);
}
}
const archives = rawRows.map(sanitizeArchiveRow);
if (!readError && !archives.length) {
configGaps.push({
id: "archive_history_data",
status: "NEEDS_CONFIG",
reason: "no_wecom_approval_archive_rows_for_scope",
});
}
const conclusion = evaluateArchiveReport({ options, archives, error: readError, configGaps });
return {
mode: "qa_only",
generatedAt,
status: conclusion.status,
conclusion: conclusion.conclusion,
privacy: REPORT_PRIVACY,
scope: {
issue: "WARCH1",
source: "OA_DB_ONLY",
offline: Boolean(options.offline),
wecomApiCalls: false,
writeBehavior: "read-only OA DB; no OA writes, no WeCom reads, no WeCom writes",
targets: options.templates,
deepMirror: "not_started",
nextStage: "wecom_attendance_approval_mirror_v1_after_WARCH1_acceptance",
},
filters: {
templates: options.templates,
status: options.status || null,
limit: options.limit,
},
checks: {
archiveRead: {
id: "archive_history_read",
status: readError ? "BLOCKED" : (archives.length ? "OK" : "NEEDS_DATA"),
count: archives.length,
statusCounts: countBy(archives, (item) => item.normalizedStatus),
templateCounts: countBy(archives, (item) => item.templateType),
singleTapApproveRiskCount: archives.filter((item) => item.singleTapApproveRisk).length,
},
archives,
},
configGaps,
mainBlockers,
};
}
function ensureReportDir(reportDir) {
fs.mkdirSync(reportDir, { recursive: true });
}
function writeReport(reportDir, report) {
ensureReportDir(reportDir);
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-approval-archive] status=${report.status} conclusion=${report.conclusion}\n`);
process.stdout.write(`[wecom-approval-archive] report=${reportPath}\n`);
process.stdout.write(`[wecom-approval-archive] privacy=${report.privacy}\n`);
process.stdout.write(`[wecom-approval-archive] offline=${report.scope.offline} targets=${report.scope.targets.join(",")}\n`);
if (report.configGaps?.length) {
process.stdout.write(`[wecom-approval-archive] configGaps=${JSON.stringify(report.configGaps)}\n`);
}
if (report.mainBlockers?.length) {
process.stdout.write(`[wecom-approval-archive] mainBlockers=${JSON.stringify(report.mainBlockers)}\n`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
const report = await executeArchiveCheck(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-approval-archive][FAIL] ${safeMessage(error?.message || error)}\n`);
process.exitCode = 1;
});
}
module.exports = {
buildWhere,
evaluateArchiveReport,
executeArchiveCheck,
parseArgs,
sanitizeArchiveRow,
};
@@ -6,6 +6,7 @@ const path = require("node:path");
const { Pool } = require("pg");
const { getConfig } = require("../config");
const { createPrismaClient } = require("../prismaClient");
const {
APPROVAL_GET_INFO_ENDPOINT,
APPROVAL_POC_TARGETS,
@@ -13,6 +14,13 @@ const {
fetchWecomApprovalInfoList,
maskApprovalIdentifier,
} = require("../services/integrations/wecomApproval");
const {
corpHash,
createCompensationRun,
finishCompensationRun,
persistApprovalDetail,
recordReconcileFinding,
} = require("../services/integrations/wecomApprovalArchive");
const { readCallbackEvidence } = require("../services/integrations/wecomCallbackCrypto");
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
@@ -47,11 +55,13 @@ Options:
--discover-applicant-employee-no=<no>
自动发现使用的 OA 申请人员工号,默认 SMOKE002。
--size=<1..100> 单页审批单号数量,默认 100。
--persist-to-db 写入 OA 自建留存表;还必须设置 WECOM_ARCHIVE_WRITE=1。
--help 显示帮助。
Secret 固定规则:
审批单号批量查询和审批详情读取:WECOM_AGENT_SECRET。
不使用 WECOM_CONTACT_SECRET、WECOM_CHECKIN_SECRET 或 WECOM_HR_ASSISTANT_SECRET 兜底。
OA DB 留存写入必须显式 --persist-to-db + WECOM_ARCHIVE_WRITE=1;不写企微数据。
隐私边界:
报告只输出审批类型、计数、状态摘要、脱敏 sp_no/template/userid 前缀和错误分类。
@@ -108,6 +118,7 @@ function parseArgs(argv) {
discoverApplicantEmployeeNo: DEFAULT_DISCOVERY_APPLICANT_EMPLOYEE_NO,
size: DEFAULT_PAGE_SIZE,
maxPages: DEFAULT_MAX_PAGES,
persistToDb: false,
help: false,
};
@@ -158,6 +169,10 @@ function parseArgs(argv) {
index += 1;
} else if (arg.startsWith("--size=")) {
options.size = Number(arg.slice("--size=".length));
} else if (arg === "--persist-to-db") {
options.persistToDb = true;
} else if (arg === "--no-persist-to-db") {
options.persistToDb = false;
} else {
throw new Error(`Unsupported argument: ${arg}`);
}
@@ -535,10 +550,21 @@ function statusLabelFromSpStatus(value) {
}[key] || (key ? `状态${key}` : null);
}
function attachHiddenValue(target, key, value) {
if (value != null && value !== "") {
Object.defineProperty(target, key, {
value,
enumerable: false,
configurable: true,
});
}
return target;
}
function summarizeDetailResult(target, spNo, result) {
const summary = result?.summary || {};
const spStatus = summary.spStatus == null ? null : toSafeString(summary.spStatus, 40);
return {
return attachHiddenValue({
target: target.key,
label: target.label,
status: result?.ok ? "OK" : "BLOCKED",
@@ -554,7 +580,7 @@ function summarizeDetailResult(target, spNo, result) {
notifyerCount: summary.notifyerCount,
commentCount: summary.commentCount,
response: summarizeApiResponse(result?.response),
};
}, "spNo", spNo);
}
function buildMissingTemplateCheck(input) {
@@ -596,6 +622,7 @@ async function runLiveTarget({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}) {
const pages = [];
const spNos = [];
@@ -655,8 +682,38 @@ async function runLiveTarget({
const uniqueSpNos = Array.from(new Set(spNos));
const details = [];
for (const spNo of uniqueSpNos) {
const detailResult = await fetchApprovalDetailImpl({ config, spNo });
details.push(summarizeDetailResult(input, spNo, detailResult));
const detailResult = await fetchApprovalDetailImpl({
config,
spNo,
includeRawPayload: Boolean(archivePersistence?.enabled),
});
const detailSummary = summarizeDetailResult(input, spNo, detailResult);
if (detailResult.ok && archivePersistence?.enabled) {
try {
const stored = await persistApprovalDetail({
prisma: archivePersistence.prisma,
config,
payload: detailResult.rawPayload,
spNo,
target: input.key,
source: "compensation_pull",
compensationRunId: archivePersistence.runId,
});
detailSummary.archivePersistence = {
status: "STORED",
archiveId: stored.archiveId,
spNoPrefix: stored.spNoPrefix,
templateIdPrefix: stored.templateIdPrefix,
};
} catch (error) {
detailSummary.archivePersistence = {
status: "FAILED",
reason: "archive_persist_failed",
message: safeMessage(error?.message || error),
};
}
}
details.push(detailSummary);
}
const detailBlocked = details.find((item) => item.status === "BLOCKED");
@@ -685,6 +742,7 @@ async function runTargetChecks({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}) {
const targetChecks = [];
for (const input of inputs) {
@@ -700,6 +758,7 @@ async function runTargetChecks({
options,
fetchApprovalInfoListImpl,
fetchApprovalDetailImpl,
archivePersistence,
}));
}
}
@@ -707,7 +766,7 @@ async function runTargetChecks({
}
function sanitizeEvidenceEvent(event) {
return {
return attachHiddenValue({
callbackKind: toSafeString(event.callbackKind, 80),
approvalType: toSafeString(event.approvalType, 80),
spStatus: event.spStatus == null ? null : toSafeString(event.spStatus, 40),
@@ -715,27 +774,85 @@ function sanitizeEvidenceEvent(event) {
spNoPrefix: toSafeString(event.spNoPrefix, 80) || null,
templateIdPrefix: toSafeString(event.templateIdPrefix, 80) || null,
recordedAt: toSafeString(event.recordedAt, 80) || null,
};
}, "spNo", event.spNo);
}
async function buildEvidenceCheck({ config, readCallbackEvidenceImpl = readCallbackEvidence }) {
const events = await readCallbackEvidenceImpl({
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
function sanitizeDbCallbackEvent(event) {
return attachHiddenValue({
callbackKind: toSafeString(event.callback_kind, 80),
approvalType: toSafeString(event.approval_type, 80),
spStatus: event.wecom_status == null ? null : toSafeString(event.wecom_status, 40),
statusLabel: null,
spNoPrefix: maskApprovalIdentifier(event.sp_no),
templateIdPrefix: maskApprovalIdentifier(event.template_id),
recordedAt: event.received_at ? new Date(event.received_at).toISOString() : null,
}, "spNo", event.sp_no);
}
async function readDbCallbackEvidence({ prisma, config }) {
if (!prisma?.wecomApprovalCallbackEvent?.findMany) return [];
const where = {
callback_kind: "approval_status_change",
};
if (config?.wecomCorpId) {
where.corp_id_hash = corpHash(config);
}
const events = await prisma.wecomApprovalCallbackEvent.findMany({
where,
orderBy: [{ received_at: "asc" }],
take: 200,
});
return events.map(sanitizeDbCallbackEvent);
}
async function buildEvidenceCheck({
config,
prisma = null,
preferDb = false,
readDbCallbackEvidenceImpl = readDbCallbackEvidence,
readCallbackEvidenceImpl = readCallbackEvidence,
}) {
let evidenceSource = "file";
let events = [];
let dbReadError = null;
if (preferDb && prisma) {
try {
events = await readDbCallbackEvidenceImpl({ prisma, config });
if (events.length) {
evidenceSource = "db";
}
} catch (error) {
dbReadError = safeMessage(error?.message || error);
events = [];
}
}
if (!events.length) {
events = await readCallbackEvidenceImpl({
evidenceFile: config.wecomApprovalCallbackEvidenceFile,
});
}
const approvalEvents = (events || [])
.filter((item) => item?.callbackKind === "approval_status_change")
.map(sanitizeEvidenceEvent);
const bySpNoPrefix = {};
const byFullSpNo = {};
for (const event of approvalEvents) {
if (!event.spNoPrefix) continue;
bySpNoPrefix[event.spNoPrefix] = bySpNoPrefix[event.spNoPrefix] || [];
bySpNoPrefix[event.spNoPrefix].push(event);
if (event.spNo) {
byFullSpNo[event.spNo] = byFullSpNo[event.spNo] || [];
byFullSpNo[event.spNo].push(event);
}
}
return {
id: "callback_event_evidence",
status: approvalEvents.length ? "OK" : "PENDING",
source: evidenceSource,
dbReadError,
eventCount: approvalEvents.length,
latestEvent: approvalEvents[approvalEvents.length - 1] || null,
byFullSpNo,
bySpNoPrefix,
};
}
@@ -752,43 +869,46 @@ function compareCallbackEvidence(targetChecks, evidenceCheck) {
const findings = [];
const pulledDetails = collectPulledDetails(targetChecks);
const evidenceBySpNo = evidenceCheck.bySpNoPrefix || {};
const evidenceByFullSpNo = evidenceCheck.byFullSpNo || {};
const pulledPrefixes = new Set();
for (const detail of pulledDetails) {
if (!detail.spNoPrefix) continue;
pulledPrefixes.add(detail.spNoPrefix);
const events = evidenceBySpNo[detail.spNoPrefix] || [];
const events = detail.spNo
? evidenceByFullSpNo[detail.spNo] || evidenceBySpNo[detail.spNoPrefix] || []
: evidenceBySpNo[detail.spNoPrefix] || [];
if (!events.length) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_missing_for_pulled_detail",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
status: "FINDING",
});
}, "spNo", detail.spNo));
continue;
}
if (events.length > 1) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_duplicate_for_pulled_detail",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
count: events.length,
status: "FINDING",
});
}, "spNo", detail.spNo));
}
const hasStatusMatch = events.some((event) => (
(event.spStatus && detail.spStatus && event.spStatus === detail.spStatus)
|| (event.statusLabel && detail.statusLabel && event.statusLabel === detail.statusLabel)
));
if (!hasStatusMatch) {
findings.push({
findings.push(attachHiddenValue({
id: "callback_status_mismatch",
target: detail.target,
spNoPrefix: detail.spNoPrefix,
pulledStatus: detail.statusLabel || detail.spStatus,
callbackStatus: events[0]?.statusLabel || events[0]?.spStatus || null,
status: "FINDING",
});
}, "spNo", detail.spNo));
}
}
@@ -863,6 +983,132 @@ function collectMainBlockers(secretCheck, targetChecks) {
return blockers;
}
function buildArchivePersistencePlan({ options, env }) {
if (!options.persistToDb) {
return {
id: "archive_persistence",
status: "SKIPPED",
enabled: false,
reason: "default_no_oa_db_writes",
writeBehavior: "disabled unless --persist-to-db and WECOM_ARCHIVE_WRITE=1 are both present",
};
}
if (!options.live) {
return {
id: "archive_persistence",
status: "NEEDS_CONFIG",
enabled: false,
reason: "live_read_required_for_archive_persistence",
missing: ["--live"],
};
}
if (env.WECOM_ARCHIVE_WRITE !== "1") {
return {
id: "archive_persistence",
status: "NEEDS_CONFIG",
enabled: false,
reason: "missing_archive_write_switch",
missing: ["WECOM_ARCHIVE_WRITE=1"],
};
}
if (!env.DATABASE_URL) {
return {
id: "archive_persistence",
status: "BLOCKED",
enabled: false,
reason: "database_url_missing",
missing: ["DATABASE_URL"],
};
}
return {
id: "archive_persistence",
status: "READY",
enabled: false,
reason: null,
writeBehavior: "OA_DB_ONLY; WeCom remains read-only",
};
}
function summarizeArchivePersistence(targetChecks, archivePersistenceCheck) {
if (!archivePersistenceCheck?.enabled) return archivePersistenceCheck;
const details = collectPulledDetails(targetChecks);
const stored = details.filter((item) => item.archivePersistence?.status === "STORED");
const failed = details.filter((item) => item.archivePersistence?.status === "FAILED");
return {
...archivePersistenceCheck,
status: failed.length ? "BLOCKED" : "OK",
storedCount: stored.length,
failedCount: failed.length,
failures: failed.map((item) => ({
target: item.target,
spNoPrefix: item.spNoPrefix,
reason: item.archivePersistence?.reason || "archive_persist_failed",
message: item.archivePersistence?.message || null,
})),
};
}
async function recordCompareFindings({ prisma, runId, compareCheck, targetChecks }) {
if (!prisma || !runId || !compareCheck?.findings?.length) return 0;
const archiveBySpNo = new Map();
const archiveByTargetPrefix = new Map();
for (const detail of collectPulledDetails(targetChecks)) {
if (detail.spNo && detail.archivePersistence?.archiveId) {
archiveBySpNo.set(detail.spNo, detail.archivePersistence.archiveId);
}
if (detail.spNoPrefix && detail.archivePersistence?.archiveId) {
archiveByTargetPrefix.set(`${detail.target}:${detail.spNoPrefix}`, detail.archivePersistence.archiveId);
}
}
let recorded = 0;
for (const finding of compareCheck.findings) {
const archiveId = (finding.spNo ? archiveBySpNo.get(finding.spNo) : null)
|| archiveByTargetPrefix.get(`${finding.target}:${finding.spNoPrefix}`)
|| null;
await recordReconcileFinding({
prisma,
runId,
archiveId,
findingType: finding.id,
severity: finding.id === "callback_status_mismatch" ? "warning" : "info",
source: "compensation_compare",
description: finding.id,
evidence: {
...finding,
spNoPrefix: finding.spNoPrefix || null,
},
});
recorded += 1;
}
return recorded;
}
function appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck }) {
if (!archivePersistenceCheck) return;
if (archivePersistenceCheck.status === "NEEDS_CONFIG") {
configGaps.push({
id: archivePersistenceCheck.id,
status: "NEEDS_CONFIG",
reason: archivePersistenceCheck.reason,
missing: archivePersistenceCheck.missing || [],
});
} else if (archivePersistenceCheck.status === "BLOCKED") {
mainBlockers.push({
id: archivePersistenceCheck.id,
reason: archivePersistenceCheck.reason || "archive_persistence_blocked",
missing: archivePersistenceCheck.missing || [],
});
}
}
function countTargetPages(targetChecks) {
return targetChecks.reduce((total, item) => total + (item.pageCount || 0), 0);
}
function countPulledItems(targetChecks) {
return targetChecks.reduce((total, item) => total + (item.spNoCount || 0), 0);
}
function evaluateConclusion({ mainBlockers, configGaps, compareCheck }) {
if (mainBlockers.length) {
return { status: "SKIP", conclusion: "BLOCKED_BY_EXTERNAL_CONFIG" };
@@ -884,6 +1130,13 @@ async function executeCompensationPoc(options, deps = {}) {
const generatedAt = deps.generatedAt || new Date().toISOString();
const config = deps.config || getConfig();
const timeWindow = buildTimeWindow(options, generatedAt);
let ownedPrisma = null;
let ownedPool = null;
let archivePersistence = { enabled: false, prisma: null, runId: null };
let archivePersistenceCheck = buildArchivePersistencePlan({ options, env });
let finishRunInput = null;
try {
const inputs = buildInputs(options, env);
const secretCheck = buildSecretCheck(config);
const discoveryCheck = await discoverTemplateIds({
@@ -896,6 +1149,35 @@ async function executeCompensationPoc(options, deps = {}) {
queryApplicantActorImpl: deps.queryApplicantActorImpl || queryApplicantActorRowReadOnly,
});
const resolvedInputs = applyDiscoveredTemplateIds(inputs, discoveryCheck);
if (archivePersistenceCheck.status === "READY") {
try {
let prisma = deps.prisma;
if (!prisma) {
const created = deps.createPrismaClientImpl ? deps.createPrismaClientImpl() : createPrismaClient();
ownedPrisma = created.prisma;
ownedPool = created.pool;
prisma = ownedPrisma;
}
const run = await createCompensationRun({ prisma, timeWindow, inputs: resolvedInputs, options });
archivePersistence = { enabled: true, prisma, runId: run.id };
archivePersistenceCheck = {
...archivePersistenceCheck,
status: "OK",
enabled: true,
runId: run.id,
startedAt: run.started_at ? new Date(run.started_at).toISOString() : null,
};
} catch (error) {
archivePersistenceCheck = {
...archivePersistenceCheck,
status: "BLOCKED",
enabled: false,
reason: "archive_persistence_unavailable",
message: safeMessage(error?.message || error),
};
archivePersistence = { enabled: false, prisma: null, runId: null };
}
}
const targetChecks = await runTargetChecks({
config,
inputs: resolvedInputs,
@@ -903,12 +1185,28 @@ async function executeCompensationPoc(options, deps = {}) {
options,
fetchApprovalInfoListImpl: deps.fetchApprovalInfoListImpl || fetchWecomApprovalInfoList,
fetchApprovalDetailImpl: deps.fetchApprovalDetailImpl || fetchWecomApprovalDetail,
archivePersistence,
});
archivePersistenceCheck = summarizeArchivePersistence(targetChecks, archivePersistenceCheck);
const evidenceCheck = await buildEvidenceCheck({
config,
prisma: archivePersistence.prisma,
preferDb: archivePersistence.enabled,
readDbCallbackEvidenceImpl: deps.readDbCallbackEvidenceImpl,
readCallbackEvidenceImpl: deps.readCallbackEvidenceImpl,
});
const compareCheck = compareCallbackEvidence(targetChecks, evidenceCheck);
const recordedFindingCount = archivePersistence.enabled && archivePersistenceCheck.status === "OK"
? await recordCompareFindings({
prisma: archivePersistence.prisma,
runId: archivePersistence.runId,
compareCheck,
targetChecks,
})
: 0;
if (archivePersistenceCheck?.enabled) {
archivePersistenceCheck.recordedFindingCount = recordedFindingCount;
}
const verificationGaps = options.live ? [] : [{
id: "live_read",
status: "DRY_RUN",
@@ -922,7 +1220,22 @@ async function executeCompensationPoc(options, deps = {}) {
});
}
const configGaps = collectConfigGaps(targetChecks, verificationGaps, evidenceCheck, compareCheck);
appendArchivePersistenceGate({ mainBlockers, configGaps, archivePersistenceCheck });
const conclusion = evaluateConclusion({ mainBlockers, configGaps, compareCheck });
if (archivePersistence.enabled) {
finishRunInput = {
prisma: archivePersistence.prisma,
runId: archivePersistence.runId,
status: mainBlockers.length ? "blocked" : (configGaps.length ? "partial" : (compareCheck.findings.length ? "findings" : "completed")),
pageCount: countTargetPages(targetChecks),
itemCount: countPulledItems(targetChecks),
detailCount: collectPulledDetails(targetChecks).length,
findingCount: recordedFindingCount,
errorCode: mainBlockers[0]?.reason || null,
errorMessage: mainBlockers[0]?.reason || null,
};
await finishCompensationRun(finishRunInput);
}
return {
mode: "qa_only",
@@ -933,11 +1246,14 @@ async function executeCompensationPoc(options, deps = {}) {
scope: {
issue: "WACP3",
targets: APPROVAL_POC_TARGETS.map((item) => item.key),
writeBehavior: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
writeBehavior: options.persistToDb
? "WeCom read-only; OA archive DB persistence requires --persist-to-db + WECOM_ARCHIVE_WRITE=1"
: "read-only only; no OA writes, no WeCom writes, no template creation, no approval submission",
liveRead: Boolean(options.live),
autoDiscovery: Boolean(options.discoverTemplateIds),
archivePersistenceRequested: Boolean(options.persistToDb),
deepMirror: "not_started",
nextStage: "WREAD1",
nextStage: options.persistToDb ? "WARCH1_OFFLINE_ARCHIVE_READ" : "WREAD1_OR_WARCH1_PERSISTENCE",
},
timeWindow,
inputs: {
@@ -957,12 +1273,30 @@ async function executeCompensationPoc(options, deps = {}) {
callbackEvidence: {
...evidenceCheck,
bySpNoPrefix: undefined,
byFullSpNo: undefined,
},
callbackCompare: compareCheck,
archivePersistence: archivePersistenceCheck,
},
configGaps,
mainBlockers,
};
} finally {
if (finishRunInput && archivePersistence.enabled && archivePersistenceCheck.status === "BLOCKED") {
await finishCompensationRun({
...finishRunInput,
status: "blocked",
errorCode: archivePersistenceCheck.reason || "archive_persistence_blocked",
errorMessage: archivePersistenceCheck.reason || "archive_persistence_blocked",
}).catch(() => null);
}
if (ownedPrisma?.$disconnect) {
await ownedPrisma.$disconnect().catch(() => null);
}
if (ownedPool?.end) {
await ownedPool.end().catch(() => null);
}
}
}
function ensureReportDir(reportDir) {
@@ -1019,6 +1353,7 @@ module.exports = {
discoverTemplateIds,
evaluateConclusion,
executeCompensationPoc,
buildArchivePersistencePlan,
parseArgs,
statusLabelFromSpStatus,
};