#!/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 SUPPORTED_TARGETS = Object.freeze(["leave", "missed_punch", "outing", "overtime"]); 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= 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-。 --template=leave 只检查请假留存。 --template=missed_punch 只检查补卡留存。 --template=outing 只检查外出留存。 --template=overtime 只检查加班留存。 --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 (!SUPPORTED_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, };