545 lines
20 KiB
TypeScript
545 lines
20 KiB
TypeScript
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 } 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<string, unknown>;
|
||
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 };
|
||
}),
|
||
}),
|
||
|
||
// ─── 检测任务 ──────────────────────────────────────────────────────────────
|
||
|
||
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);
|
||
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 }) => {
|
||
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;
|