import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc"; 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"; 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"; import { deleteAnalysisResult } from "./analysisResultDeletion.js"; import { clearLoginFailures, createSessionToken, hashPassword, isLoginRateLimited, publicUser, recordLoginFailure, validatePassword, verifyPassword, } from "./_core/auth"; const DATA_DIR = process.env.DATA_DIR || "/data"; const TASKS_DIR = path.join(DATA_DIR, "tasks"); function pointcloudDisplayName(baseNameInput: string, filePath: string): string { const baseName = baseNameInput.trim(); if (!baseName) { throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" }); } if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) { throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" }); } const name = `${baseName}${path.extname(filePath)}`; if (Array.from(name).length > 255) { throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" }); } return name; } const DEMO_MEASUREMENT_PLUGIN_IDS = [ "train-measurement", "coupler-panel", "flatness-suite", "underframe-deflection", ] as const; const DEMO_REGISTERED_POINTCLOUD_PLUGIN_IDS = new Set([ "coupler-panel", "flatness-suite", "underframe-deflection", ]); async function validateRegistrationRunInput(input: { taskId: number; pointCloudFileIds?: number[]; cadFileIds?: number[]; allowDuplicateRegistration?: boolean; }) { const pointCloudFileIds = input.pointCloudFileIds ?? []; const cadFileIds = input.cadFileIds ?? []; if (pointCloudFileIds.length !== 1 || cadFileIds.length !== 1) { throw new TRPCError({ code: "BAD_REQUEST", message: "整车配准必须恰好选择 1 个点云文件和 1 个 CAD 文件。", }); } const pointcloudFiles = await db.listTaskPointcloudFiles(input.taskId); const pointcloud = pointcloudFiles.find((row) => row.id === pointCloudFileIds[0]); if (!pointcloud) { throw new TRPCError({ code: "NOT_FOUND", message: "选中的点云文件不存在。" }); } if (pointcloud.source === "registered") { throw new TRPCError({ code: "BAD_REQUEST", message: "已配准点云不能再次作为配准输入,请选择原始点云进行配准。", }); } const cadFiles = await db.listTaskCadFiles(input.taskId); const cad = cadFiles.find((row) => row.id === cadFileIds[0]); if (!cad) { throw new TRPCError({ code: "NOT_FOUND", message: "选中的 CAD 文件不存在。" }); } const registeredChildren = pointcloudFiles .filter((row) => row.source === "registered" && row.sourcePointcloudFileId === pointcloud.id) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); if (registeredChildren.length > 0 && !input.allowDuplicateRegistration) { const latest = registeredChildren[0]; throw new TRPCError({ code: "CONFLICT", message: `点云「${pointcloud.name}」已有配准产物「${latest.name}」,重复配准需要确认。`, }); } } function latestRegisteredPointcloud( pointcloudFiles: Awaited>, 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(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 { if (pluginId === "flatness-suite") { return { useBatchDatasets: true, module: "contour", }; } return {}; } function defaultParamsForManifest(manifest: NonNullable>): Record { const defaults: Record = {}; for (const field of manifest.inputs ?? []) { if (field.default !== undefined) { defaults[field.name] = field.default; } } return defaults; } function mergePluginParams( manifest: NonNullable>, params?: Record, ): Record { return { ...defaultParamsForManifest(manifest), ...(params ?? {}), }; } async function createAndEnqueuePluginJob(input: { taskId: number; pluginId: string; params?: Record; pointCloudFileIds?: number[]; cadFileIds?: number[]; resultData?: Record; }) { 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(); 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>, reason: string, ): Record { const previous = (row?.resultData as Record | 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, auth: router({ login: publicProcedure .input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) })) .mutation(async ({ input, ctx }) => { const username = input.username.trim(); const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown"; if (isLoginRateLimited(ip, username)) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" }); } const user = await db.getUserByUsername(username); const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null)); if (!user || !valid) { recordLoginFailure(ip, username); throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" }); } clearLoginFailures(ip, username); await db.updateUser(user.id, { lastLoginAt: new Date() }); const token = await createSessionToken(user); ctx.res.cookie(COOKIE_NAME, token, { ...getSessionCookieOptions(ctx.req), maxAge: SESSION_TTL_MS, }); return publicUser({ ...user, lastLoginAt: new Date() }); }), me: sessionProcedure.query(opts => publicUser(opts.ctx.user)), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), changePassword: sessionProcedure .input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) })) .mutation(async ({ input, ctx }) => { if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) { throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" }); } if (input.currentPassword === input.newPassword) { throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" }); } validatePassword(input.newPassword); const passwordHash = await hashPassword(input.newPassword); const nextVersion = ctx.user.sessionVersion + 1; await db.updateUser(ctx.user.id, { passwordHash, mustChangePassword: 0, sessionVersion: nextVersion, }); const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion }); ctx.res.cookie(COOKIE_NAME, token, { ...getSessionCookieOptions(ctx.req), maxAge: SESSION_TTL_MS, }); return { success: true } as const; }), }), users: router({ list: superAdminProcedure .input(z.object({ search: z.string().max(100).optional() }).optional()) .query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)), create: superAdminProcedure .input(z.object({ username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"), temporaryPassword: z.string().min(8).max(256), name: z.string().trim().min(1).max(100), email: z.string().email().max(320).optional().or(z.literal("")), role: z.enum(["user", "admin", "super_admin"]), })) .mutation(async ({ input }) => { try { const row = await db.createUser({ username: input.username, passwordHash: await hashPassword(input.temporaryPassword), name: input.name, email: input.email || null, role: input.role, isActive: 1, mustChangePassword: 1, sessionVersion: 0, }); if (!row) throw new Error("创建用户失败"); return publicUser(row); } catch (error) { if (isDuplicateKeyError(error)) { throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" }); } throw error; } }), update: superAdminProcedure .input(z.object({ id: z.number().int().positive(), username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(), name: z.string().trim().min(1).max(100).optional(), email: z.string().email().max(320).optional().or(z.literal("")), role: z.enum(["user", "admin", "super_admin"]).optional(), })) .mutation(async ({ input, ctx }) => { const target = await db.getUserById(input.id); if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); if (target.id === ctx.user.id && input.role && input.role !== target.role) { throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" }); } if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) { throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" }); } const { id, email, ...rest } = input; try { const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) }); if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); return publicUser(row); } catch (error) { if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" }); throw error; } }), setActive: superAdminProcedure .input(z.object({ id: z.number().int().positive(), isActive: z.boolean() })) .mutation(async ({ input, ctx }) => { const target = await db.getUserById(input.id); if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" }); if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) { throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" }); } if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) { throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" }); } const row = await db.updateUser(target.id, { isActive: input.isActive ? 1 : 0, sessionVersion: target.sessionVersion + 1, }); return publicUser(row!); }), resetPassword: superAdminProcedure .input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) })) .mutation(async ({ input, ctx }) => { if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" }); const target = await db.getUserById(input.id); if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); await db.updateUser(target.id, { passwordHash: await hashPassword(input.temporaryPassword), mustChangePassword: 1, sessionVersion: target.sessionVersion + 1, }); return { success: true } as const; }), delete: superAdminProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" }); const target = await db.getUserById(input.id); if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" }); if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) { throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" }); } await db.deleteUser(target.id); return { success: true } as const; }), }), // ─── 车系 ────────────────────────────────────────────────────────────────── vehicleSeries: router({ list: protectedProcedure.query(async () => { return db.listVehicleSeries(); }), create: protectedProcedure .input(z.object({ name: z.string().min(1).max(100), description: z.string().optional(), })) .mutation(async ({ input }) => { return db.createVehicleSeries({ name: input.name, description: input.description ?? null, }); }), update: protectedProcedure .input(z.object({ id: z.number(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; await db.updateVehicleSeries(id, data); return { success: true }; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteVehicleSeries(input.id); return { success: true }; }), }), // ─── 车型 ────────────────────────────────────────────────────────────────── vehicleModel: router({ listBySeries: protectedProcedure .input(z.object({ seriesId: z.number() })) .query(async ({ input }) => { return db.listVehicleModelsBySeries(input.seriesId); }), get: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { return db.getVehicleModelById(input.id); }), create: protectedProcedure .input(z.object({ seriesId: z.number(), name: z.string().min(1).max(100), description: z.string().optional(), })) .mutation(async ({ input }) => { return db.createVehicleModel({ seriesId: input.seriesId, name: input.name, description: input.description ?? null, }); }), update: protectedProcedure .input(z.object({ id: z.number(), name: z.string().min(1).max(100).optional(), description: z.string().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; await db.updateVehicleModel(id, data); return { success: true }; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteVehicleModel(input.id); return { success: true }; }), }), // ─── 车型 CAD 文件 ───────────────────────────────────────────────────────── cadFile: router({ list: protectedProcedure .input(z.object({ modelId: z.number() })) .query(async ({ input }) => { return db.listModelCadFiles(input.modelId); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteModelCadFile(input.id); return { success: true }; }), /** STEP 解析后的 manifest(零件列表与 STL 相对路径) */ assemblyManifest: protectedProcedure .input(z.object({ modelId: z.number(), cadFileId: z.number() })) .query(async ({ input }) => { const row = await db.getModelCadFileById(input.cadFileId); if (!row || row.modelId !== input.modelId) { throw new TRPCError({ code: "NOT_FOUND", message: "CAD 文件不存在" }); } const manPath = path.join(stepParseCacheDir(input.modelId, input.cadFileId), "manifest.json"); if (!fs.existsSync(manPath)) { return { ready: false as const, manifest: null as null }; } const manifest = JSON.parse(fs.readFileSync(manPath, "utf-8")) as Record; return { ready: true as const, manifest }; }), }), // ─── 车型点云文件 ───────────────────────────────────────────────────────── modelPointcloudFile: router({ list: protectedProcedure .input(z.object({ modelId: z.number() })) .query(async ({ input }) => { return db.listModelPointcloudFiles(input.modelId); }), rename: adminProcedure .input(z.object({ id: z.number(), baseName: z.string() })) .mutation(async ({ input }) => { const row = await db.getModelPointcloudFileById(input.id); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "车型库点云不存在" }); } const name = pointcloudDisplayName(input.baseName, row.filePath); if (name === row.name) return row; const duplicate = await db.findModelPointcloudFileByModelAndName(row.modelId, name, row.id); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: "当前车型下已存在同名点云" }); } return db.updateModelPointcloudFileName(row.id, name); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteModelPointcloudFile(input.id); return { success: true }; }), }), // ─── 任务 CAD 文件 ──────────────────────────────────────────────────────────── taskCadFile: router({ list: protectedProcedure .input(z.object({ taskId: z.number() })) .query(async ({ input }) => { return db.listTaskCadFiles(input.taskId); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteTaskCadFile(input.id); return { success: true }; }), linkFromModel: protectedProcedure .input(z.object({ taskId: z.number(), modelCadFileIds: z.array(z.number()).min(1), })) .mutation(async ({ input }) => { try { const files = await db.linkTaskCadFilesFromModel(input.taskId, input.modelCadFileIds); return { success: true, files }; } catch (e) { const message = e instanceof Error ? e.message : String(e); if (message.includes("another model")) { throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的 CAD 库文件" }); } if (message.includes("not found")) { throw new TRPCError({ code: "NOT_FOUND", message: "任务或 CAD 库文件不存在" }); } throw e; } }), // 检查 CAD 文件是否已有三角化采样缓存(.cad_sample_cache/ 目录下存在对应 PLY) hasSampleCache: protectedProcedure .input(z.object({ cadFileIds: z.array(z.number()) })) .query(async ({ input }) => { const result: Record = {}; for (const id of input.cadFileIds) { const row = await db.getTaskCadFileById(id); if (!row?.filePath) { result[id] = false; continue; } const cacheDir = path.join(path.dirname(row.filePath), '.cad_sample_cache'); try { const files = fs.readdirSync(cacheDir); result[id] = files.some(f => f.endsWith('.ply')); } catch { result[id] = false; } } return result; }), }), // ─── 检测任务 ────────────────────────────────────────────────────────────── inspectionTask: router({ listByModel: protectedProcedure .input(z.object({ modelId: z.number() })) .query(async ({ input }) => { return db.listInspectionTasksByModel(input.modelId); }), get: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { return db.getInspectionTaskById(input.id); }), create: protectedProcedure .input(z.object({ modelId: z.number(), name: z.string().trim().min(1).max(255), inspector: z.string().max(100).optional(), notes: z.string().optional(), })) .mutation(async ({ input }) => { 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 .input(z.object({ id: z.number(), name: z.string().max(255).optional(), inspector: z.string().max(100).optional(), notes: z.string().optional(), status: z.enum(["created", "pending", "processing", "completed", "error"]).optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; await db.updateInspectionTask(id, data); return { success: true }; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { // 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。 const cancelled = [ ...cancelAlgorithmJobsByTask(input.id), ...cancelLegacyAnalysisJobsByTask(input.id), ]; if (cancelled.length > 0) { 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 }; }), }), // ─── 任务点云文件 ──────────────────────────────────────────────────────────── pointcloudFile: router({ list: protectedProcedure .input(z.object({ taskId: z.number() })) .query(async ({ input }) => { return db.listTaskPointcloudFiles(input.taskId); }), rename: adminProcedure .input(z.object({ id: z.number(), baseName: z.string() })) .mutation(async ({ input }) => { const row = await db.getTaskPointcloudFileById(input.id); if (!row) { throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" }); } const name = pointcloudDisplayName(input.baseName, row.filePath); if (name === row.name) return row; const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id); if (duplicate) { throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" }); } return db.updateTaskPointcloudFileName(row.id, name); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteTaskPointcloudFile(input.id); return { success: true }; }), linkFromModel: protectedProcedure .input(z.object({ taskId: z.number(), modelPointcloudFileIds: z.array(z.number()).min(1), })) .mutation(async ({ input }) => { try { const files = await db.linkTaskPointcloudFilesFromModel(input.taskId, input.modelPointcloudFileIds); return { success: true, files }; } catch (e) { const message = e instanceof Error ? e.message : String(e); if (message.includes("another model")) { throw new TRPCError({ code: "BAD_REQUEST", message: "只能关联当前车型的点云库文件" }); } if (message.includes("not found")) { throw new TRPCError({ code: "NOT_FOUND", message: "任务或点云库文件不存在" }); } throw e; } }), }), // ─── 任务分析结果 ──────────────────────────────────────────────────────────── analysisResult: router({ list: protectedProcedure .input(z.object({ taskId: z.number() })) .query(async ({ input }) => { return db.listTaskAnalysisResults(input.taskId); }), runAnalysis: protectedProcedure .input(z.object({ taskId: z.number(), type: z.enum([ "error_analysis", "measurement", "compression", "smooth_heatmap", "assembly_inspection", "obj_deviation", ]), /** 点云采样预算:平滑热力默认 40 万,偏差热力(GPU)可提高到千万级 */ totalPoints: z.number().min(50_000).max(20_000_000).optional(), /** 装配检测:指定 STEP 的 model_cad_files.id;不填则取该车型下第一个 STEP */ cadFileId: z.number().optional(), /** 装配检测:点云采样点数,默认 10 万 */ samplePoints: z.number().min(10_000).max(500_000).optional(), /** 装配检测:合格阈值(mm) */ toleranceMm: z.number().positive().max(100).optional(), /** 装配检测:是否生成每零件顶点热力缓存 */ includeVertexHeatmap: z.boolean().optional(), })) .mutation(async ({ input }) => { if (input.type === "assembly_inspection") { try { const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: "assembly_inspection", status: "processing", }); enqueueAssemblyInspectionJob(record.id, input.taskId, { cadFileId: input.cadFileId, samplePoints: input.samplePoints ?? 100_000, toleranceMm: input.toleranceMm ?? 0.3, includeVertexHeatmap: input.includeVertexHeatmap ?? false, }); return { success: true as const, id: record.id, kind: "assembly_inspection" as const }; } catch (e: unknown) { const err = e as { errno?: number; message?: string }; const msg = err.message ?? ""; if ( err.errno === 1265 || err.errno === 1366 || msg.includes("Data truncated") || (msg.includes("Incorrect") && msg.includes("column 'type'")) ) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "数据库缺少 assembly_inspection 类型枚举,请应用迁移 drizzle/0005_task_analysis_assembly_inspection.sql 后重启。", }); } throw e; } } if (input.type === "smooth_heatmap") { try { const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: "smooth_heatmap", status: "processing", }); enqueueSmoothHeatmapJob(record.id, input.taskId, input.totalPoints ?? 400_000); return { success: true as const, id: record.id, kind: "smooth_heatmap" as const }; } catch (e: unknown) { const err = e as { errno?: number; message?: string }; const msg = err.message ?? ""; if ( err.errno === 1265 || err.errno === 1366 || msg.includes("Data truncated") || msg.includes("Incorrect") && msg.includes("column 'type'") ) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "数据库 task_analysis_results.type 枚举缺少 smooth_heatmap。请重启服务以应用迁移 drizzle/0004_task_analysis_smooth_heatmap_enum.sql,或在 MySQL 执行 scripts/alter-task-analysis-type-enum.sql。", }); } throw e; } } if (input.type === "obj_deviation") { try { const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: "obj_deviation", status: "processing", }); enqueueDeviationHeatmapJob(record.id, input.taskId, input.totalPoints ?? 5_000_000); return { success: true as const, id: record.id, kind: "obj_deviation" as const }; } catch (e: unknown) { const err = e as { errno?: number; message?: string }; const msg = err.message ?? ""; if ( err.errno === 1265 || err.errno === 1366 || msg.includes("Data truncated") || (msg.includes("Incorrect") && msg.includes("column 'type'")) ) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "数据库 task_analysis_results.type 枚举缺少 obj_deviation。请应用迁移 drizzle/0006_task_analysis_obj_deviation.sql 后重启。", }); } throw e; } } const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: input.type, status: "processing", }); await new Promise(resolve => setTimeout(resolve, 1500)); const errorStats = { min: 0.001, max: 2.543, mean: 0.856, stdDev: 0.423, totalPoints: 1000000, withinTolerance: 892345, outOfTolerance: 107655, }; const measurements = [ { name: "车体长度", value: 25700.4, unit: "mm", tolerance: 5, status: "pass" }, { name: "车体宽度", value: 3380.1, unit: "mm", tolerance: 3, status: "pass" }, { name: "车体高度", value: 3700.2, unit: "mm", tolerance: 3, status: "pass" }, { name: "轮对距D1", value: 1435.8, unit: "mm", tolerance: 1, status: "warning" }, { name: "转向架轴距", value: 2500.3, unit: "mm", tolerance: 2, status: "pass" }, ]; await db.updateTaskAnalysisResult(record.id, { status: "completed", errorStats, measurements, }); await db.updateInspectionTask(input.taskId, { status: "completed", completedAt: new Date(), }); 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 }) => { return deleteAnalysisResult(input.id); }), }), // ─── 通用算法插件(Route B) ────────────────────────────────────────────── // // 不为每类算法做独立界面:前端按 manifest.inputs 动态渲染表单 + 多选并发执行; // 后端按 manifest.runtime 启动子进程或返回 mock fixture; // 结果统一落到 task_analysis_results.type = 'plugin',pluginId 区分。 algorithm: router({ /** 列出所有已注册插件(前端 AlgorithmRunPanel 用) */ list: protectedProcedure.query(() => { return { plugins: listPlugins().map((m) => ({ id: m.id, name: m.name, shortName: m.shortName, description: m.description, version: m.version, schemaVersion: m.schemaVersion, category: m.category, inputs: m.inputs ?? [], requiresCad: m.requiresCad ?? false, requiresPointCloud: m.requiresPointCloud ?? true, runtimeKind: m.runtime.kind, })), scheduler: algorithmJobStats(), }; }), /** 单插件执行:创建一条 'plugin' 类型的分析结果记录,丢进并发池 */ run: protectedProcedure .input( z.object({ taskId: z.number(), pluginId: z.string().min(1), params: z.record(z.string(), z.unknown()).optional(), /** 可选:只处理选中的点云文件 ID(空/缺省 = 任务下全部文件) */ pointCloudFileIds: z.array(z.number()).optional(), /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ cadFileIds: z.array(z.number()).optional(), /** 配准:允许同一原始点云再次生成配准产物 */ allowDuplicateRegistration: z.boolean().optional(), }), ) .mutation(async ({ input }) => { const manifest = getPlugin(input.pluginId); if (!manifest) { throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${input.pluginId}`, }); } if (manifest.category === "配准") { await validateRegistrationRunInput(input); } try { const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: "plugin", pluginId: manifest.id, schemaVersion: manifest.schemaVersion, status: "processing", }); void enqueueAlgorithmJob( record.id, input.taskId, manifest.id, mergePluginParams(manifest, input.params), input.pointCloudFileIds, input.cadFileIds, ); return { success: true as const, id: record.id, pluginId: manifest.id, schemaVersion: manifest.schemaVersion, }; } catch (e: unknown) { const err = e as { errno?: number; message?: string }; const msg = err.message ?? ""; if ( err.errno === 1265 || err.errno === 1366 || msg.includes("Data truncated") || (msg.includes("Incorrect") && msg.includes("column 'type'")) || msg.includes("Unknown column 'pluginId'") ) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "数据库缺少 type='plugin' 枚举或 pluginId/schemaVersion 列。请应用迁移 drizzle/0007_task_analysis_plugin_columns.sql 后重启。", }); } throw e; } }), /** 多插件并发执行:每个插件都通过相同的 run 流程入池 */ runBatch: protectedProcedure .input( z.object({ taskId: z.number(), /** 可选:只处理选中的点云文件 ID(空/缺省 = 任务下全部文件) */ pointCloudFileIds: z.array(z.number()).optional(), /** 可选:任务级 CAD 文件 ID(空/缺省 = 不传 CAD) */ cadFileIds: z.array(z.number()).optional(), /** 配准:允许同一原始点云再次生成配准产物 */ allowDuplicateRegistration: z.boolean().optional(), jobs: z .array( z.object({ pluginId: z.string().min(1), params: z.record(z.string(), z.unknown()).optional(), }), ) .min(1) .max(16), }), ) .mutation(async ({ input }) => { const created: { id: number; pluginId: string }[] = []; const hasRegistrationJob = input.jobs.some((job) => getPlugin(job.pluginId)?.category === "配准"); if (hasRegistrationJob) { await validateRegistrationRunInput(input); } for (const job of input.jobs) { const manifest = getPlugin(job.pluginId); if (!manifest) continue; const record = await db.createTaskAnalysisResult({ taskId: input.taskId, type: "plugin", pluginId: manifest.id, schemaVersion: manifest.schemaVersion, status: "processing", }); void enqueueAlgorithmJob( record.id, input.taskId, manifest.id, mergePluginParams(manifest, job.params), input.pointCloudFileIds, input.cadFileIds, ); created.push({ id: record.id, pluginId: manifest.id }); } 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, "用户停止一键全流程演示"); const rows = await db.listTaskAnalysisResults(input.taskId); const latestDemoRow = rows.find((row) => { const data = row.resultData as Record | null; return typeof data?.demoFlowRunId === "string"; }); if (latestDemoRow) { const latestDemoData = latestDemoRow.resultData as Record; const latestRunId = latestDemoData.demoFlowRunId; const cancelledAt = new Date().toISOString(); // 即使停止发生在“配准完成、测量记录尚未创建”的短暂窗口, // 也在本次链路的全部现有记录上留下结束标记,避免刷新后重新显示为排队中。 await Promise.all( rows .filter((row) => { const data = row.resultData as Record | null; return data?.demoFlowRunId === latestRunId; }) .map((row) => db.patchTaskAnalysisResultData(row.id, { demoFlowCancelledAt: cancelledAt })), ); } return { ...result, scheduler: algorithmJobStats(), }; }), }), }); export type AppRouter = typeof appRouter;