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