完善 readiness、发布追溯与敏感治理(07-15-production-trust-closeout-cleanup)
这个提交包含在:
+116
-65
@@ -1,78 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { PrismaClient } from "../../server/node_modules/@prisma/client/index.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function parseThresholds() {
|
||||
const arg = process.argv.find((item) => item.startsWith("--thresholds="));
|
||||
export function parseThresholds(argv = process.argv.slice(2)) {
|
||||
const arg = argv.find((item) => item.startsWith("--thresholds="));
|
||||
const raw = arg ? arg.slice("--thresholds=".length) : "2,5,10";
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
.sort((a, b) => a - b);
|
||||
return Array.from(new Set(
|
||||
raw
|
||||
.split(",")
|
||||
.map((item) => Number(item.trim()))
|
||||
.filter((item) => Number.isFinite(item) && item > 0)
|
||||
.map((item) => Math.floor(item)),
|
||||
)).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function minutesAgo(minutes) {
|
||||
return new Date(Date.now() - minutes * 60_000);
|
||||
function minutesAgo(minutes, now) {
|
||||
return new Date(now.getTime() - minutes * 60_000);
|
||||
}
|
||||
|
||||
export async function buildPendingOcrSummary({
|
||||
databaseReady,
|
||||
thresholds,
|
||||
countPending,
|
||||
now = new Date(),
|
||||
}) {
|
||||
if (!databaseReady) {
|
||||
return {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: "SKIP",
|
||||
findingCount: 1,
|
||||
findings: [{ code: "database_not_configured", severity: "manual_or_config" }],
|
||||
thresholds: [],
|
||||
checkedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const thresholdResults = [];
|
||||
for (const thresholdMinutes of thresholds) {
|
||||
const count = Number(await countPending({
|
||||
status: "pending_ocr",
|
||||
created_at: { lte: minutesAgo(thresholdMinutes, now) },
|
||||
}));
|
||||
thresholdResults.push({
|
||||
thresholdMinutes,
|
||||
count: Number.isFinite(count) && count > 0 ? Math.floor(count) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
const findingCount = thresholdResults.reduce((maximum, item) => Math.max(maximum, item.count), 0);
|
||||
return {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: findingCount > 0 ? "PASS_WITH_FINDINGS" : "PASS",
|
||||
findingCount,
|
||||
findings: findingCount > 0
|
||||
? [{ code: "pending_ocr_detected", severity: "finding", count: findingCount }]
|
||||
: [],
|
||||
thresholds: thresholdResults,
|
||||
checkedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function executePendingOcrCheck({
|
||||
databaseReady = Boolean(process.env.DATABASE_URL?.trim()),
|
||||
thresholds = parseThresholds(),
|
||||
} = {}) {
|
||||
if (!databaseReady) {
|
||||
return buildPendingOcrSummary({ databaseReady: false, thresholds, countPending: async () => 0 });
|
||||
}
|
||||
|
||||
const prismaClientModule = await import("../../server/src/prismaClient.js");
|
||||
const createPrismaClient = prismaClientModule.createPrismaClient
|
||||
?? prismaClientModule.default?.createPrismaClient;
|
||||
if (typeof createPrismaClient !== "function") {
|
||||
throw new Error("Prisma client factory is unavailable");
|
||||
}
|
||||
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
try {
|
||||
return await buildPendingOcrSummary({
|
||||
databaseReady: true,
|
||||
thresholds,
|
||||
countPending: (where) => prisma.invoicePool.count({ where }),
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary(summary, jsonOnly) {
|
||||
if (jsonOnly) {
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return;
|
||||
}
|
||||
console.log(`[pending-ocr][${summary.status}] findings=${summary.findingCount}`);
|
||||
for (const item of summary.thresholds) {
|
||||
console.log(`[pending-ocr] thresholdMinutes=${item.thresholdMinutes} count=${item.count}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log("[pending-ocr][SKIP] missing DATABASE_URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const thresholds = parseThresholds();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const jsonOnly = process.argv.includes("--json");
|
||||
try {
|
||||
const summary = [];
|
||||
for (const minutes of thresholds) {
|
||||
const where = {
|
||||
status: "pending_ocr",
|
||||
created_at: { lte: minutesAgo(minutes) },
|
||||
};
|
||||
const count = await prisma.invoicePool.count({ where });
|
||||
const rows = await prisma.invoicePool.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "asc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
ocr_attempts: true,
|
||||
ocr_last_requested_at: true,
|
||||
ocr_last_error: true,
|
||||
ocr_locked_at: true,
|
||||
ocr_lock_owner: true,
|
||||
},
|
||||
});
|
||||
|
||||
summary.push({
|
||||
thresholdMinutes: minutes,
|
||||
count,
|
||||
sampleLimit: 50,
|
||||
items: rows.map((item) => ({
|
||||
id: item.id,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
attempts: item.ocr_attempts ?? 0,
|
||||
lastRequestedAt: item.ocr_last_requested_at?.toISOString?.() ?? null,
|
||||
lastError: item.ocr_last_error ?? null,
|
||||
lockedAt: item.ocr_locked_at?.toISOString?.() ?? null,
|
||||
lockOwner: item.ocr_lock_owner ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ ok: true, thresholds: summary }, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
const summary = await executePendingOcrCheck();
|
||||
printSummary(summary, jsonOnly);
|
||||
} catch {
|
||||
const summary = {
|
||||
mode: "pending-ocr-readiness",
|
||||
status: "FAIL",
|
||||
findingCount: 1,
|
||||
findings: [{ code: "pending_ocr_check_failed", severity: "blocker" }],
|
||||
thresholds: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
printSummary(summary, jsonOnly);
|
||||
console.error("[pending-ocr][FAIL] database check failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[pending-ocr][FAIL] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
await main();
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户