276 lines
9.6 KiB
JavaScript
276 lines
9.6 KiB
JavaScript
#!/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<string, unknown>;
|
|
|
|
export interface CandidateRow extends RowDataPacket {
|
|
id: number;
|
|
taskId: number;
|
|
measurements: unknown;
|
|
resultData: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface BackfillPlan {
|
|
row: CandidateRow;
|
|
resultJsonPath: string;
|
|
originalResultJson: Record<string, unknown>;
|
|
measurements: Measurement[];
|
|
}
|
|
|
|
export interface ExecutionSummary {
|
|
planned: number;
|
|
applied: number;
|
|
skipped: number;
|
|
failed: number;
|
|
}
|
|
|
|
export function mergeMeasurements<T extends Record<string, unknown>>(
|
|
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<boolean>,
|
|
): Promise<ExecutionSummary> {
|
|
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<Measurement[]> {
|
|
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<CandidateRow[]> {
|
|
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<CandidateRow[]>(
|
|
`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<string, unknown>;
|
|
const extra = resultJson.extra as Record<string, unknown> | undefined;
|
|
const resultDataExtra = row.resultData?.extra as Record<string, unknown> | 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<void> {
|
|
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<string> {
|
|
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<boolean> {
|
|
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<ResultSetHeader>(
|
|
`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<void> {
|
|
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<void> {
|
|
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;
|
|
});
|
|
}
|