434 行
14 KiB
JavaScript
434 行
14 KiB
JavaScript
#!/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=<path> 报告目录,默认 artifacts/qa-flow/wecom-approval-archive-<timestamp>。
|
|
--template=leave 只检查请假留存。
|
|
--template=missed_punch 只检查补卡留存。
|
|
--template=outing 只检查外出留存。
|
|
--template=overtime 只检查加班留存。
|
|
--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 (!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,
|
|
};
|