import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import * as fs from "fs"; import * as path from "path"; 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"; export const appRouter = router({ system: systemRouter, auth: router({ me: publicProcedure.query(opts => 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; }), }), // ─── 车系 ────────────────────────────────────────────────────────────────── 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 }; }), }), // ─── 任务 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 }; }), // 检查 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().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", }); }), 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(["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 }) => { await db.deleteInspectionTask(input.id); // 取消该任务下所有正在运行或排队的算法 job const cancelled = cancelAlgorithmJobsByTask(input.id); if (cancelled.length > 0) { console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个算法 job:`, cancelled); } return { success: true }; }), }), // ─── 任务点云文件 ──────────────────────────────────────────────────────────── pointcloudFile: router({ list: protectedProcedure .input(z.object({ taskId: z.number() })) .query(async ({ input }) => { return db.listTaskPointcloudFiles(input.taskId); }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteTaskPointcloudFile(input.id); return { success: true }; }), }), // ─── 任务分析结果 ──────────────────────────────────────────────────────────── 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 }; }), delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { // 先取消正在运行或排队的 job,再删数据库记录 cancelAlgorithmJob(input.id); await db.deleteTaskAnalysisResult(input.id); return { success: true }; }), }), // ─── 通用算法插件(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(), }), ) .mutation(async ({ input }) => { const manifest = getPlugin(input.pluginId); if (!manifest) { throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${input.pluginId}`, }); } 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, 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(), 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 }[] = []; 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, job.params ?? {}, input.pointCloudFileIds, input.cadFileIds, ); created.push({ id: record.id, pluginId: manifest.id }); } return { success: true as const, jobs: created, scheduler: algorithmJobStats() }; }), }), }); export type AppRouter = typeof appRouter;