feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
544
server/routers.ts
Normal file
544
server/routers.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user