feat: add inspection task lifecycle controls

This commit is contained in:
2026-07-08 17:44:45 +08:00
parent aa2bd80418
commit b87aa3f990
14 changed files with 901 additions and 28 deletions

View File

@@ -6,6 +6,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import * as db from "./db";
import { enqueueAssemblyInspectionJob } from "./assemblyInspectionJob.js";
import { enqueueDeviationHeatmapJob } from "./deviationHeatmapJob.js";
@@ -13,6 +14,22 @@ import { enqueueSmoothHeatmapJob } from "./smoothHeatmapJob.js";
import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
const DATA_DIR = process.env.DATA_DIR || "/data";
const TASKS_DIR = path.join(DATA_DIR, "tasks");
const DEMO_MEASUREMENT_PLUGIN_IDS = [
"train-measurement",
"coupler-panel",
"flatness-suite",
"underframe-deflection",
] as const;
const DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS = new Set<string>([
"coupler-panel",
"flatness-suite",
"underframe-deflection",
]);
async function validateRegistrationRunInput(input: {
taskId: number;
@@ -60,6 +77,231 @@ async function validateRegistrationRunInput(input: {
}
}
function latestRegisteredPointcloud(
pointcloudFiles: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>,
sourcePointcloudFileId: number,
) {
return pointcloudFiles
.filter((row) => row.source === "registered" && row.sourcePointcloudFileId === sourcePointcloudFileId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
}
function normalizeDemoMeasurementPluginIds(input?: string[]) {
const ids = input && input.length > 0 ? input : [...DEMO_MEASUREMENT_PLUGIN_IDS];
const unique = Array.from(new Set(ids));
const supported = new Set<string>(DEMO_MEASUREMENT_PLUGIN_IDS);
const unsupported = unique.filter((id) => !supported.has(id));
if (unsupported.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `一键全流程演示不支持算法:${unsupported.join(", ")}`,
});
}
return unique;
}
function paramsForDemoPlugin(pluginId: string): Record<string, unknown> {
if (pluginId === "flatness-suite") {
return {
useBatchDatasets: true,
module: "contour",
};
}
return {};
}
function defaultParamsForManifest(manifest: NonNullable<ReturnType<typeof getPlugin>>): Record<string, unknown> {
const defaults: Record<string, unknown> = {};
for (const field of manifest.inputs ?? []) {
if (field.default !== undefined) {
defaults[field.name] = field.default;
}
}
return defaults;
}
function mergePluginParams(
manifest: NonNullable<ReturnType<typeof getPlugin>>,
params?: Record<string, unknown>,
): Record<string, unknown> {
return {
...defaultParamsForManifest(manifest),
...(params ?? {}),
};
}
async function createAndEnqueuePluginJob(input: {
taskId: number;
pluginId: string;
params?: Record<string, unknown>;
pointCloudFileIds?: number[];
cadFileIds?: number[];
resultData?: Record<string, unknown>;
}) {
const manifest = getPlugin(input.pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${input.pluginId}`,
});
}
const record = await db.createTaskAnalysisResult({
taskId: input.taskId,
type: "plugin",
pluginId: manifest.id,
schemaVersion: manifest.schemaVersion,
status: "processing",
resultData: input.resultData,
});
const promise = enqueueAlgorithmJob(
record.id,
input.taskId,
manifest.id,
mergePluginParams(manifest, input.params),
input.pointCloudFileIds,
input.cadFileIds,
);
return {
id: record.id,
pluginId: manifest.id,
promise,
};
}
const DUPLICATE_INSPECTION_TASK_NAME_MESSAGE = "该车型下已存在同名检测任务。";
function duplicateInspectionTaskNameError() {
return new TRPCError({
code: "CONFLICT",
message: DUPLICATE_INSPECTION_TASK_NAME_MESSAGE,
});
}
function isDuplicateKeyError(error: unknown) {
const err = error as { code?: string; errno?: number; message?: string };
return err?.code === "ER_DUP_ENTRY" || err?.errno === 1062 ||
(typeof err?.message === "string" && err.message.includes("uk_inspection_tasks_model_name"));
}
const CANCELLABLE_ANALYSIS_STATUSES = new Set(["pending", "processing"]);
const cancelledDemoTaskIds = new Set<number>();
function markDemoFlowCancelled(taskId: number): void {
cancelledDemoTaskIds.add(taskId);
}
function clearDemoFlowCancelled(taskId: number): void {
cancelledDemoTaskIds.delete(taskId);
}
function isDemoFlowCancelled(taskId: number): boolean {
return cancelledDemoTaskIds.has(taskId);
}
function cancelledResultData(
row: Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>,
reason: string,
): Record<string, unknown> {
const previous = (row?.resultData as Record<string, unknown> | null) ?? {};
return {
...previous,
cancelled: true,
cancelReason: reason,
cancelledAt: new Date().toISOString(),
stage: "已停止",
};
}
async function cancelTaskAnalysisResultById(id: number, reason = "用户停止任务") {
const row = await db.getTaskAnalysisResultById(id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "分析结果不存在。" });
}
if (!CANCELLABLE_ANALYSIS_STATUSES.has(row.status)) {
return { success: true as const, id, cancelled: false as const, status: row.status };
}
const pluginCancelled = cancelAlgorithmJob(id);
const legacyCancelled = cancelLegacyAnalysisJob(id);
await db.updateTaskAnalysisResult(id, {
status: "cancelled",
resultData: cancelledResultData(row, reason),
});
return {
success: true as const,
id,
cancelled: true as const,
pluginCancelled,
legacyCancelled,
};
}
async function cancelTaskAnalysisResultsByTask(taskId: number, reason = "用户停止任务") {
const rows = await db.listTaskAnalysisResults(taskId);
const cancellable = rows.filter((row) => CANCELLABLE_ANALYSIS_STATUSES.has(row.status));
const pluginCancelled = cancelAlgorithmJobsByTask(taskId);
const legacyCancelled = cancelLegacyAnalysisJobsByTask(taskId);
for (const row of cancellable) {
await db.updateTaskAnalysisResult(row.id, {
status: "cancelled",
resultData: cancelledResultData(row, reason),
});
}
return {
success: true as const,
cancelledIds: cancellable.map((row) => row.id),
pluginCancelled,
legacyCancelled,
};
}
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
registrationResultId: number;
measurementPluginIds: string[];
demoFlowRunId: string;
}) {
const registrationResult = await db.getTaskAnalysisResultById(input.registrationResultId);
if (registrationResult?.status !== "completed") {
return;
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const registered = latestRegisteredPointcloud(pointcloudFiles, input.sourcePointCloudFileId);
if (!registered) {
console.error(
`[runDemoAll] 配准完成但未找到 registered 点云 taskId=${input.taskId} sourcePointCloudFileId=${input.sourcePointCloudFileId}`,
);
return;
}
for (let idx = 0; idx < input.measurementPluginIds.length; idx += 1) {
const pluginId = input.measurementPluginIds[idx];
const manifest = getPlugin(pluginId);
await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId,
params: paramsForDemoPlugin(pluginId),
pointCloudFileIds: DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS.has(pluginId)
? [registered.id]
: [input.sourcePointCloudFileId],
cadFileIds: manifest?.requiresCad ? [input.cadFileId] : undefined,
resultData: {
demoFlowRunId: input.demoFlowRunId,
demoFlowRole: "measurement",
demoFlowIndex: idx + 2,
demoFlowTotal: input.measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
},
});
}
}
export const appRouter = router({
system: systemRouter,
@@ -285,18 +527,30 @@ export const appRouter = router({
create: protectedProcedure
.input(z.object({
modelId: z.number(),
name: z.string().min(1).max(255),
name: z.string().trim().min(1).max(255),
inspector: z.string().max(100).optional(),
notes: z.string().optional(),
}))
.mutation(async ({ input }) => {
return db.createInspectionTask({
modelId: input.modelId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
status: "pending",
});
const existing = await db.findInspectionTaskByModelAndName(input.modelId, input.name);
if (existing) {
throw duplicateInspectionTaskNameError();
}
try {
return await db.createInspectionTask({
modelId: input.modelId,
name: input.name,
inspector: input.inspector ?? null,
notes: input.notes ?? null,
status: "created",
});
} catch (error) {
if (isDuplicateKeyError(error)) {
throw duplicateInspectionTaskNameError();
}
throw error;
}
}),
update: protectedProcedure
@@ -305,7 +559,7 @@ export const appRouter = router({
name: z.string().max(255).optional(),
inspector: z.string().max(100).optional(),
notes: z.string().optional(),
status: z.enum(["pending", "processing", "completed", "error"]).optional(),
status: z.enum(["created", "pending", "processing", "completed", "error"]).optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
@@ -316,12 +570,19 @@ export const appRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteInspectionTask(input.id);
// 取消该任务下所有正在运行或排队的算法 job
const cancelled = cancelAlgorithmJobsByTask(input.id);
// 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。
const cancelled = [
...cancelAlgorithmJobsByTask(input.id),
...cancelLegacyAnalysisJobsByTask(input.id),
];
if (cancelled.length > 0) {
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个算法 job:`, cancelled);
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个分析 job:`, cancelled);
}
await db.deleteInspectionTask(input.id);
const taskDir = path.join(TASKS_DIR, String(input.id));
await fs.promises.rm(taskDir, { recursive: true, force: true });
return { success: true };
}),
}),
@@ -524,11 +785,25 @@ export const appRouter = router({
return { success: true, id: record.id, errorStats, measurements };
}),
cancel: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
return cancelTaskAnalysisResultById(input.id, "用户停止分析任务");
}),
cancelByTask: protectedProcedure
.input(z.object({ taskId: z.number() }))
.mutation(async ({ input }) => {
markDemoFlowCancelled(input.taskId);
return cancelTaskAnalysisResultsByTask(input.taskId, "用户停止检测任务下的运行任务");
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
// 先取消正在运行或排队的 job,再删数据库记录
cancelAlgorithmJob(input.id);
cancelLegacyAnalysisJob(input.id);
await db.deleteTaskAnalysisResult(input.id);
return { success: true };
}),
@@ -600,7 +875,7 @@ export const appRouter = router({
record.id,
input.taskId,
manifest.id,
input.params ?? {},
mergePluginParams(manifest, input.params),
input.pointCloudFileIds,
input.cadFileIds,
);
@@ -672,7 +947,7 @@ export const appRouter = router({
record.id,
input.taskId,
manifest.id,
job.params ?? {},
mergePluginParams(manifest, job.params),
input.pointCloudFileIds,
input.cadFileIds,
);
@@ -680,6 +955,109 @@ export const appRouter = router({
}
return { success: true as const, jobs: created, scheduler: algorithmJobStats() };
}),
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
runDemoAll: protectedProcedure
.input(
z.object({
taskId: z.number(),
sourcePointCloudFileId: z.number(),
cadFileId: z.number(),
measurementPluginIds: z.array(z.string().min(1)).optional(),
}),
)
.mutation(async ({ input }) => {
const measurementPluginIds = normalizeDemoMeasurementPluginIds(input.measurementPluginIds);
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const pluginId of measurementPluginIds) {
const manifest = getPlugin(pluginId);
if (!manifest) {
throw new TRPCError({
code: "NOT_FOUND",
message: `未注册的算法插件:${pluginId}`,
});
}
if (manifest.category === "配准") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "一键全流程演示的测量算法列表不能包含配准算法。",
});
}
}
const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId);
const sourcePointcloud = pointcloudFiles.find((row) => row.id === input.sourcePointCloudFileId);
if (!sourcePointcloud) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的原始点云文件不存在。" });
}
if (sourcePointcloud.source === "registered") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "请选择原始点云,系统会自动找到对应配准产物。",
});
}
const cadFiles = await db.listTaskCadFiles(input.taskId);
const cad = cadFiles.find((row) => row.id === input.cadFileId);
if (!cad) {
throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" });
}
const registration = await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId: "vehicle-registration",
params: {},
pointCloudFileIds: [input.sourcePointCloudFileId],
cadFileIds: [input.cadFileId],
resultData: {
demoFlowRunId,
demoFlowRole: "registration",
demoFlowIndex: 1,
demoFlowTotal: measurementPluginIds.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise
.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[runDemoAll] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementPluginIds,
demoFlowRunId,
});
})
.catch((err) => {
console.error("[runDemoAll] 配准链式提交失败:", err);
});
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: measurementPluginIds.length,
scheduler: algorithmJobStats(),
};
}),
cancelDemoAll: protectedProcedure
.input(z.object({ taskId: z.number() }))
.mutation(async ({ input }) => {
markDemoFlowCancelled(input.taskId);
const result = await cancelTaskAnalysisResultsByTask(input.taskId, "用户停止一键全流程演示");
return {
...result,
scheduler: algorithmJobStats(),
};
}),
}),
});