Files
massive-cloud/server/routers.ts

1520 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { buildDemoTaskCatalog, resolveSelectedDemoJobs } from "./demoFlow.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
import {
inspectionObjectContextFromName,
logCompatibilityWarnings,
normalizeMeasurements,
} from "./measurementCompatibility.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<string>([
"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 文件不存在。" });
}
await validateTaskInputCompatibility(input.taskId, pointcloud, 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}」,重复配准需要确认。`,
});
}
}
async function validateTaskInputCompatibility(
taskId: number,
pointcloud: Awaited<ReturnType<typeof db.listTaskPointcloudFiles>>[number],
cad: Awaited<ReturnType<typeof db.listTaskCadFiles>>[number],
) {
const task = await db.getInspectionTaskById(taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
if (!task.inspectionObjectId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "历史任务尚未确认检测对象,请联系管理员完成归类后再运行算法。" });
}
if (pointcloud.source === "model_library" && pointcloud.sourceModelPointcloudId) {
const source = await db.getModelPointcloudFileById(pointcloud.sourceModelPointcloudId);
if (!source || source.inspectionObjectId !== task.inspectionObjectId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云与当前检测对象不兼容。" });
}
}
if (cad.source === "model_library" && cad.sourceModelCadFileId) {
if (!await db.cadAppliesToInspectionObject(cad.sourceModelCadFileId, task.inspectionObjectId)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "CAD 与当前检测对象不兼容。" });
}
}
}
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,
};
}
interface DemoFlowMeasurementJob {
pluginId: string;
params?: Record<string, unknown>;
taskNos?: number[];
}
interface DemoFlowMetadata {
demoFlowMode: "all" | "selected";
demoFlowSelectedTaskNos?: number[];
demoFlowSelectedTaskCount?: number;
}
async function enqueueDemoRegisteredMeasurementsAfterRegistration(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
registrationResultId: number;
measurementJobs: DemoFlowMeasurementJob[];
demoFlowRunId: string;
metadata: DemoFlowMetadata;
}) {
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.measurementJobs.length; idx += 1) {
const job = input.measurementJobs[idx];
const pluginId = job.pluginId;
const manifest = getPlugin(pluginId);
await createAndEnqueuePluginJob({
taskId: input.taskId,
pluginId,
params: { ...paramsForDemoPlugin(pluginId), ...(job.params ?? {}) },
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.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
...(job.taskNos ? { demoFlowPluginTaskNos: job.taskNos } : {}),
},
});
}
}
async function startDemoFlow(input: {
taskId: number;
sourcePointCloudFileId: number;
cadFileId: number;
measurementJobs: DemoFlowMeasurementJob[];
metadata: DemoFlowMetadata;
}) {
const demoFlowRunId = randomUUID();
clearDemoFlowCancelled(input.taskId);
for (const job of input.measurementJobs) {
const manifest = getPlugin(job.pluginId);
if (!manifest) throw new TRPCError({ code: "NOT_FOUND", message: `未注册的算法插件:${job.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 文件不存在。" });
await validateTaskInputCompatibility(input.taskId, sourcePointcloud, 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: input.measurementJobs.length + 1,
demoFlowSourcePointCloudFileId: input.sourcePointCloudFileId,
demoFlowCadFileId: input.cadFileId,
...input.metadata,
},
});
const registrationJob = { id: registration.id, pluginId: registration.pluginId };
void registration.promise.then(() => {
if (isDemoFlowCancelled(input.taskId)) {
console.log(`[demoFlow] 任务 ${input.taskId} 已停止全流程,跳过后续测量提交。`);
return undefined;
}
return enqueueDemoRegisteredMeasurementsAfterRegistration({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
registrationResultId: registration.id,
measurementJobs: input.measurementJobs,
demoFlowRunId,
metadata: input.metadata,
});
}).catch((err) => console.error("[demoFlow] 配准链式提交失败:", err));
return {
success: true as const,
jobs: [] as { id: number; pluginId: string }[],
registrationJob,
queuedMeasurementCount: input.measurementJobs.length,
scheduler: algorithmJobStats(),
};
}
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 };
}),
}),
// ─── 检测对象配置 ─────────────────────────────────────────────────────────
inspectionObject: router({
list: protectedProcedure
.input(z.object({ modelId: z.number(), activeOnly: z.boolean().optional() }))
.query(({ input }) => db.listInspectionObjects(input.modelId, input.activeOnly ?? false)),
create: adminProcedure
.input(z.object({ modelId: z.number(), name: z.string().trim().min(1).max(100), description: z.string().optional() }))
.mutation(({ input }) => db.createInspectionObject({ ...input, description: input.description ?? null, status: "draft" })),
update: adminProcedure
.input(z.object({ id: z.number(), name: z.string().trim().min(1).max(100).optional(), description: z.string().optional() }))
.mutation(({ input }) => {
const { id, ...data } = input;
return db.updateInspectionObject(id, data);
}),
configureDefaults: adminProcedure
.input(z.object({ id: z.number(), defaultModelPointcloudId: z.number(), defaultModelCadFileId: z.number(), active: z.boolean() }))
.mutation(async ({ input }) => {
try { return await db.configureInspectionObject(input); }
catch (error) { throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "默认输入方案无效" }); }
}),
setStatus: adminProcedure
.input(z.object({ id: z.number(), status: z.enum(["draft", "active"]) }))
.mutation(async ({ input }) => {
if (input.status === "active") {
const object = await db.getInspectionObjectById(input.id);
if (!object?.defaultModelPointcloudId || !object.defaultModelCadFileId) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "缺少预置点云或默认 CAD,不能启用检测对象" });
}
return db.configureInspectionObject({
id: object.id,
defaultModelPointcloudId: object.defaultModelPointcloudId,
defaultModelCadFileId: object.defaultModelCadFileId,
active: true,
});
}
return db.updateInspectionObject(input.id, { status: "draft" });
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
await db.deleteInspectionObject(input.id);
return { success: true };
} catch (error) {
if (error instanceof db.InspectionObjectReferencedError) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: error.message });
}
if (error instanceof Error && error.message === "检测对象不存在") {
throw new TRPCError({ code: "NOT_FOUND", message: error.message });
}
throw error;
}
}),
}),
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────
cadFile: router({
list: protectedProcedure
.input(z.object({ modelId: z.number() }))
.query(async ({ input }) => {
const files = await db.listModelCadFiles(input.modelId);
const objectIds = await db.listCadInspectionObjectIds(files.map(file => file.id));
return files.map(file => ({ ...file, inspectionObjectIds: objectIds[file.id] ?? [] }));
}),
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelCadFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
setInspectionObjects: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectIds: z.array(z.number()).min(1) }))
.mutation(async ({ input }) => {
const cad = await db.getModelCadFileById(input.id);
if (!cad) throw new TRPCError({ code: "NOT_FOUND", message: "CAD 文件不存在" });
const objects = await Promise.all(input.inspectionObjectIds.map(id => db.getInspectionObjectById(id)));
if (objects.some(object => !object || object.modelId !== cad.modelId)) throw new TRPCError({ code: "BAD_REQUEST", message: "适用检测对象必须属于 CAD 当前车型" });
await db.setCadInspectionObjects(input.id, input.inspectionObjectIds);
return { success: true };
}),
delete: adminProcedure
.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 };
}),
}),
// ─── 车型点云文件 ─────────────────────────────────────────────────────────
modelPointcloudFile: router({
list: protectedProcedure
.input(z.object({ modelId: z.number() }))
.query(async ({ input }) => {
return db.listModelPointcloudFiles(input.modelId);
}),
listForInspectionObject: protectedProcedure
.input(z.object({ modelId: z.number(), inspectionObjectId: z.number() }))
.query(({ input }) => db.listModelPointcloudFilesForInspectionObject(input.modelId, input.inspectionObjectId)),
assignInspectionObject: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(({ input }) => db.assignModelPointcloudInspectionObject(input.id, input.inspectionObjectId)),
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: adminProcedure
.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<number, boolean> = {};
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);
}),
classify: adminProcedure
.input(z.object({ id: z.number(), inspectionObjectId: z.number() }))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.id);
const object = await db.getInspectionObjectById(input.inspectionObjectId);
if (!task || !object) throw new TRPCError({ code: "NOT_FOUND", message: "任务或检测对象不存在" });
if (task.modelId !== object.modelId || object.status !== "active") {
throw new TRPCError({ code: "BAD_REQUEST", message: "只能确认当前车型下已启用的检测对象" });
}
await db.classifyInspectionTask(task.id, object.id);
return { success: true };
}),
create: protectedProcedure
.input(z.object({
modelId: z.number(),
inspectionObjectId: 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.createInspectionTaskWithDefaults({
modelId: input.modelId,
inspectionObjectId: input.inspectionObjectId,
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 }) => {
const [rows, task] = await Promise.all([
db.listTaskAnalysisResults(input.taskId),
db.getInspectionTaskById(input.taskId),
]);
const inspectionObject = task?.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return rows.map((row) => {
const resultData = row.resultData && typeof row.resultData === 'object' && !Array.isArray(row.resultData)
? { ...(row.resultData as Record<string, unknown>) }
: {};
const pluginId = row.pluginId ?? (typeof resultData.pluginId === 'string' ? resultData.pluginId : undefined);
const rawMeasurements = Array.isArray(row.measurements)
? row.measurements
: resultData.measurements;
if (!pluginId || !Array.isArray(rawMeasurements)) return row;
const normalized = normalizeMeasurements({
pluginId,
measurements: rawMeasurements,
extra: resultData.extra,
context,
algorithmProtocolVersion: row.schemaVersion ?? (resultData.schemaVersion as string | number | undefined),
});
logCompatibilityWarnings(row.id, normalized.warnings);
return {
...row,
measurements: normalized.measurements,
resultData: {
...resultData,
measurements: normalized.measurements,
extra: normalized.extra,
},
};
});
}),
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() };
}),
/** 当前检测对象可用的正式任务目录。 */
demoTaskCatalog: protectedProcedure
.input(z.object({ taskId: z.number() }))
.query(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
return buildDemoTaskCatalog(listPlugins(), context.inspectionObject, context.inspectionObjectName);
}),
/** 一键全流程演示:先整车配准,成功后再一起提交测量算法。 */
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);
return startDemoFlow({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
measurementJobs: measurementPluginIds.map((pluginId) => ({ pluginId })),
metadata: { demoFlowMode: "all" },
});
}),
/** 自选任务全流程:服务端校验任务并只提交覆盖所选任务的最小插件集合。 */
runDemoSelected: protectedProcedure
.input(z.object({
taskId: z.number(),
sourcePointCloudFileId: z.number(),
cadFileId: z.number(),
taskNos: z.array(z.number().int().positive()).min(1).max(64),
}))
.mutation(async ({ input }) => {
const task = await db.getInspectionTaskById(input.taskId);
if (!task) throw new TRPCError({ code: "NOT_FOUND", message: "检测任务不存在。" });
const inspectionObject = task.inspectionObjectId
? await db.getInspectionObjectById(task.inspectionObjectId)
: undefined;
const context = inspectionObjectContextFromName(inspectionObject?.name);
const manifests = listPlugins();
const catalog = buildDemoTaskCatalog(manifests, context.inspectionObject, context.inspectionObjectName);
let selectedPlan: ReturnType<typeof resolveSelectedDemoJobs>;
try {
selectedPlan = resolveSelectedDemoJobs(manifests, catalog, input.taskNos);
} catch (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "所选任务无效" });
}
const result = await startDemoFlow({
taskId: input.taskId,
sourcePointCloudFileId: input.sourcePointCloudFileId,
cadFileId: input.cadFileId,
measurementJobs: selectedPlan.jobs,
metadata: {
demoFlowMode: "selected",
demoFlowSelectedTaskNos: selectedPlan.taskNos,
demoFlowSelectedTaskCount: selectedPlan.taskNos.length,
},
});
return { ...result, selectedTaskNos: selectedPlan.taskNos };
}),
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<string, unknown> | null;
return typeof data?.demoFlowRunId === "string";
});
if (latestDemoRow) {
const latestDemoData = latestDemoRow.resultData as Record<string, unknown>;
const latestRunId = latestDemoData.demoFlowRunId;
const cancelledAt = new Date().toISOString();
// 即使停止发生在“配准完成、测量记录尚未创建”的短暂窗口,
// 也在本次链路的全部现有记录上留下结束标记,避免刷新后重新显示为排队中。
await Promise.all(
rows
.filter((row) => {
const data = row.resultData as Record<string, unknown> | null;
return data?.demoFlowRunId === latestRunId;
})
.map((row) => db.patchTaskAnalysisResultData(row.id, { demoFlowCancelledAt: cancelledAt })),
);
}
return {
...result,
scheduler: algorithmJobStats(),
};
}),
}),
});
export type AppRouter = typeof appRouter;