#!/usr/bin/env node import { execFile as execFileCallback } from "node:child_process"; import { promises as fs } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { pathToFileURL } from "node:url"; import mysql, { type Pool, type ResultSetHeader, type RowDataPacket } from "mysql2/promise"; const execFile = promisify(execFileCallback); export type Measurement = Record; export interface CandidateRow extends RowDataPacket { id: number; taskId: number; measurements: unknown; resultData: Record | null; } export interface BackfillPlan { row: CandidateRow; resultJsonPath: string; originalResultJson: Record; measurements: Measurement[]; } export interface ExecutionSummary { planned: number; applied: number; skipped: number; failed: number; } export function mergeMeasurements>( value: T | null, measurements: Measurement[], ): T & { measurements: Measurement[] } { return { ...(value ?? ({} as T)), measurements }; } export async function executePlans( plans: BackfillPlan[], apply: boolean, persist: (plan: BackfillPlan) => Promise, ): Promise { const summary: ExecutionSummary = { planned: plans.length, applied: 0, skipped: apply ? 0 : plans.length, failed: 0, }; if (!apply) return summary; for (const plan of plans) { try { if (await persist(plan)) summary.applied += 1; else summary.skipped += 1; } catch (error) { summary.failed += 1; console.error(`[backfill] 结果 ${plan.row.id} 写入失败:`, error); } } return summary; } function argumentValue(name: string): string | undefined { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } function timestamp(): string { return new Date().toISOString().replace(/[:.]/g, "-"); } async function parseMeasurements( excelPath: string, templatePath: string, helperPath: string, ): Promise { const python = process.env.PYTHON3_PATH || "/usr/bin/python3"; const { stdout } = await execFile( python, [helperPath, "--excel", excelPath, "--template", templatePath], { maxBuffer: 10 * 1024 * 1024 }, ); const parsed = JSON.parse(stdout) as unknown; if (!Array.isArray(parsed) || parsed.length === 0) { throw new Error("解析结果为空") } return parsed.filter((item): item is Measurement => !!item && typeof item === "object"); } async function listCandidates(pool: Pool, resultId?: number): Promise { const filters = [ "pluginId = 'flatness-suite'", "status = 'completed'", "(measurements IS NULL OR JSON_LENGTH(measurements) = 0)", ]; const params: number[] = []; if (resultId !== undefined) { filters.push("id = ?"); params.push(resultId); } const [rows] = await pool.query( `SELECT id, taskId, measurements, resultData FROM task_analysis_results WHERE ${filters.join(" AND ")} ORDER BY id`, params, ); return rows; } async function buildPlans( rows: CandidateRow[], dataDir: string, templatePath: string, helperPath: string, ): Promise<{ plans: BackfillPlan[]; skipped: number }> { const plans: BackfillPlan[] = []; let skipped = 0; for (const row of rows) { const resultDir = path.join(dataDir, "tasks", String(row.taskId), "analysis", String(row.id)); const resultJsonPath = path.join(resultDir, "result.json"); const excelPath = path.join(resultDir, "contour.xlsx"); try { const [resultText] = await Promise.all([ fs.readFile(resultJsonPath, "utf8"), fs.access(excelPath), ]); const resultJson = JSON.parse(resultText) as Record; const extra = resultJson.extra as Record | undefined; const resultDataExtra = row.resultData?.extra as Record | undefined; if (extra?.module !== "contour_batch" && resultDataExtra?.module !== "contour_batch") { throw new Error("不是 contour_batch 批量结果") } const measurements = await parseMeasurements(excelPath, templatePath, helperPath); plans.push({ row, resultJsonPath, originalResultJson: resultJson, measurements }); console.log(`[backfill] 结果 ${row.id}: 可回填 ${measurements.length} 条测量值`); } catch (error) { skipped += 1; console.warn(`[backfill] 跳过结果 ${row.id}: ${error instanceof Error ? error.message : String(error)}`); } } return { plans, skipped }; } async function writeJsonAtomically(filePath: string, value: unknown): Promise { const temporaryPath = `${filePath}.backfill-${process.pid}.tmp`; await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); await fs.rename(temporaryPath, filePath); } async function writeBackup(plans: BackfillPlan[], dataDir: string): Promise { const backupDir = path.join(dataDir, "maintenance-backups"); await fs.mkdir(backupDir, { recursive: true }); const backupPath = path.join(backupDir, `flatness-suite-measurements-${timestamp()}.json`); await writeJsonAtomically(backupPath, { version: 1, createdAt: new Date().toISOString(), entries: plans.map((plan) => ({ id: plan.row.id, taskId: plan.row.taskId, resultJsonPath: plan.resultJsonPath, measurements: plan.row.measurements, resultData: plan.row.resultData, resultJson: plan.originalResultJson, })), }); return backupPath; } async function persistPlan(pool: Pool, plan: BackfillPlan): Promise { const connection = await pool.getConnection(); const updatedResultJson = mergeMeasurements(plan.originalResultJson, plan.measurements); const updatedResultData = mergeMeasurements(plan.row.resultData, plan.measurements); try { await connection.beginTransaction(); await writeJsonAtomically(plan.resultJsonPath, updatedResultJson); const [result] = await connection.execute( `UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite' AND status = 'completed' AND (measurements IS NULL OR JSON_LENGTH(measurements) = 0)`, [JSON.stringify(plan.measurements), JSON.stringify(updatedResultData), plan.row.id], ); if (result.affectedRows !== 1) { await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson); await connection.rollback(); return false; } await connection.commit(); return true; } catch (error) { await connection.rollback(); await writeJsonAtomically(plan.resultJsonPath, plan.originalResultJson).catch(() => undefined); throw error; } finally { connection.release(); } } interface BackupManifest { entries: Array<{ id: number; resultJsonPath: string; measurements: unknown; resultData: unknown; resultJson: unknown; }>; } async function restoreBackup(pool: Pool, backupPath: string): Promise { const manifest = JSON.parse(await fs.readFile(backupPath, "utf8")) as BackupManifest; if (!Array.isArray(manifest.entries)) throw new Error("备份清单格式无效") for (const entry of manifest.entries) { await writeJsonAtomically(entry.resultJsonPath, entry.resultJson); await pool.execute( "UPDATE task_analysis_results SET measurements = ?, resultData = ? WHERE id = ? AND pluginId = 'flatness-suite'", [ entry.measurements == null ? null : JSON.stringify(entry.measurements), entry.resultData == null ? null : JSON.stringify(entry.resultData), entry.id, ], ); console.log(`[backfill] 已恢复结果 ${entry.id}`); } } async function main(): Promise { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error("缺少 DATABASE_URL") const dataDir = process.env.DATA_DIR || "/data"; const algRoot = process.env.ALG_ROOT || "/alg"; const resultIdRaw = argumentValue("--result-id"); const resultId = resultIdRaw === undefined ? undefined : Number(resultIdRaw); if (resultIdRaw !== undefined && !Number.isInteger(resultId)) throw new Error("--result-id 必须是整数") const apply = process.argv.includes("--apply"); const restorePath = argumentValue("--restore"); const templatePath = path.join(algRoot, "平整度、平面度、轮廓测量v1.5", "excel", "测量结果.xlsx"); const helperPath = path.resolve("server/plugins/adapters/flatness_suite_measurements.py"); const pool = mysql.createPool(databaseUrl); try { if (restorePath) { await restoreBackup(pool, restorePath); return; } const rows = await listCandidates(pool, resultId); const { plans, skipped } = await buildPlans(rows, dataDir, templatePath, helperPath); let backupPath: string | undefined; if (apply && plans.length > 0) { backupPath = await writeBackup(plans, dataDir); console.log(`[backfill] 写入前备份: ${backupPath}`); } const summary = await executePlans(plans, apply, (plan) => persistPlan(pool, plan)); summary.skipped += skipped; console.log(`[backfill] ${apply ? "apply" : "dry-run"} 完成: ${JSON.stringify(summary)}`); if (!apply && plans.length > 0) console.log("[backfill] 确认无误后追加 --apply 执行写入"); if (backupPath) console.log(`[backfill] 恢复命令: pnpm tsx scripts/backfill-flatness-suite-measurements.ts --restore ${backupPath}`); } finally { await pool.end(); } } const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; if (isMain) { main().catch((error) => { console.error("[backfill] 失败:", error); process.exitCode = 1; }); }