feat: add inspection task lifecycle controls
This commit is contained in:
@@ -130,10 +130,11 @@ CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`inspector` VARCHAR(100),
|
||||
`notes` TEXT,
|
||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
||||
`status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created',
|
||||
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`completedAt` TIMESTAMP NULL
|
||||
`completedAt` TIMESTAMP NULL,
|
||||
UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
||||
@@ -148,8 +149,8 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
||||
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`taskId` INT NOT NULL,
|
||||
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation') NOT NULL,
|
||||
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
|
||||
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation','plugin') NOT NULL,
|
||||
`status` ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending',
|
||||
`errorStats` JSON,
|
||||
`measurements` JSON,
|
||||
`resultData` JSON,
|
||||
|
||||
11
drizzle/0012_inspection_task_created_status.sql
Normal file
11
drizzle/0012_inspection_task_created_status.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 检测任务新增 created 初始状态;task_analysis_results.status 仍保留原有语义
|
||||
|
||||
ALTER TABLE `inspection_tasks`
|
||||
MODIFY COLUMN `status` ENUM('created','pending','processing','completed','error') NOT NULL DEFAULT 'created';
|
||||
|
||||
UPDATE `inspection_tasks` AS t
|
||||
LEFT JOIN `task_analysis_results` AS r
|
||||
ON r.`taskId` = t.`id`
|
||||
SET t.`status` = 'created'
|
||||
WHERE t.`status` = 'pending'
|
||||
AND r.`id` IS NULL;
|
||||
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
3
drizzle/0013_task_analysis_cancelled_status.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- 任务分析结果新增 cancelled:停止后保留记录与进度,不按 error 展示。
|
||||
ALTER TABLE task_analysis_results
|
||||
MODIFY COLUMN status ENUM('pending','processing','completed','error','cancelled') NOT NULL DEFAULT 'pending';
|
||||
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
2
drizzle/0014_inspection_task_unique_model_name.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `inspection_tasks`
|
||||
ADD UNIQUE KEY `uk_inspection_tasks_model_name` (`modelId`, `name`);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json } from "drizzle-orm/mysql-core";
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
||||
|
||||
// ─── 用户表(保持不变) ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,10 +106,14 @@ export const inspectionTasks = mysqlTable("inspection_tasks", {
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
inspector: varchar("inspector", { length: 100 }),
|
||||
notes: text("notes"),
|
||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
||||
status: mysqlEnum("status", ["created", "pending", "processing", "completed", "error"]).default("created").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
completedAt: timestamp("completedAt"),
|
||||
}, (table) => {
|
||||
return {
|
||||
modelNameUnique: uniqueIndex("uk_inspection_tasks_model_name").on(table.modelId, table.name),
|
||||
};
|
||||
});
|
||||
|
||||
export type InspectionTask = typeof inspectionTasks.$inferSelect;
|
||||
@@ -158,7 +162,7 @@ export const taskAnalysisResults = mysqlTable("task_analysis_results", {
|
||||
pluginId: varchar("pluginId", { length: 64 }),
|
||||
/** 算法 result.json 的 schema 版本,便于回放时严格校验 */
|
||||
schemaVersion: int("schemaVersion"),
|
||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error"]).default("pending").notNull(),
|
||||
status: mysqlEnum("status", ["pending", "processing", "completed", "error", "cancelled"]).default("pending").notNull(),
|
||||
errorStats: json("errorStats"), // { min, max, mean, stdDev, totalPoints, withinTolerance, outOfTolerance }
|
||||
measurements: json("measurements"), // [{ name, value, unit, tolerance, status }]
|
||||
resultData: json("resultData"), // 其他分析数据(如压缩比等)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 主进程不直接 spawn Python,而是 fork Node runner —— 与现有
|
||||
* smoothHeatmapJob / deviationHeatmapJob 的架构保持一致。
|
||||
*/
|
||||
import { fork, ChildProcess } from 'node:child_process';
|
||||
import { fork, ChildProcess, execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -67,7 +67,7 @@ class ConcurrencyPool {
|
||||
child.kill('SIGKILL');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return killOrphanAlgorithmProcesses({ resultId });
|
||||
}
|
||||
|
||||
/** 取消某个 taskId 下所有 job(删除任务时用)。返回被取消的 resultId 数组。 */
|
||||
@@ -85,13 +85,17 @@ class ConcurrencyPool {
|
||||
}
|
||||
this.queue = keep;
|
||||
// 运行中 kill
|
||||
for (const [resultId, child] of this.activeChildren) {
|
||||
for (const [resultId, child] of Array.from(this.activeChildren.entries())) {
|
||||
const jobTaskId = (child as ChildProcess & { _taskId?: number })._taskId;
|
||||
if (jobTaskId === taskId) {
|
||||
child.kill('SIGKILL');
|
||||
cancelled.push(resultId);
|
||||
}
|
||||
}
|
||||
const orphaned = killOrphanAlgorithmProcesses({ taskId });
|
||||
if (orphaned.length > 0) {
|
||||
cancelled.push(...orphaned);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
@@ -155,6 +159,113 @@ function resolveAlgorithmRunnerEntry(): string {
|
||||
|
||||
const pool = new ConcurrencyPool(DEFAULT_CONCURRENCY);
|
||||
|
||||
interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function listProcesses(): ProcessRow[] {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
command: match[3],
|
||||
};
|
||||
})
|
||||
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
|
||||
} catch (err) {
|
||||
console.warn('[AlgorithmJob] 无法列出进程,跳过孤儿进程清理:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
|
||||
const all = new Set(rootPids);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const proc of processes) {
|
||||
if (!all.has(proc.pid) && all.has(proc.ppid)) {
|
||||
all.add(proc.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function killPids(pids: number[]): number[] {
|
||||
const killed: number[] = [];
|
||||
for (const pid of pids) {
|
||||
if (pid <= 1 || pid === process.pid) continue;
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
killed.push(pid);
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
function killOrphanAlgorithmProcesses(input: { resultId: number }): boolean;
|
||||
function killOrphanAlgorithmProcesses(input: { taskId: number }): number[];
|
||||
function killOrphanAlgorithmProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
|
||||
const processes = listProcesses();
|
||||
const roots = new Set<number>();
|
||||
const resultIds = new Set<number>();
|
||||
|
||||
for (const proc of processes) {
|
||||
const cmd = proc.command;
|
||||
if (input.resultId != null) {
|
||||
const resultId = String(input.resultId);
|
||||
if (
|
||||
new RegExp(`algorithmJobRunner\\.(?:ts|js)\\s+${resultId}\\s+`).test(cmd) ||
|
||||
cmd.includes(`/analysis/${resultId}/input.json`)
|
||||
) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(input.resultId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (input.taskId != null) {
|
||||
const runnerMatch = cmd.match(/algorithmJobRunner\.(?:ts|js)\s+(\d+)\s+(\d+)\s+/);
|
||||
if (runnerMatch && Number(runnerMatch[2]) === input.taskId) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(Number(runnerMatch[1]));
|
||||
continue;
|
||||
}
|
||||
const inputJsonMatch = cmd.match(new RegExp(`/data/tasks/${input.taskId}/analysis/(\\d+)/input\\.json`));
|
||||
if (inputJsonMatch) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(Number(inputJsonMatch[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
|
||||
const killed = killPids(targets);
|
||||
if (killed.length > 0) {
|
||||
console.warn(`[AlgorithmJob] 已清理孤儿算法进程 pid=${killed.join(', ')}`);
|
||||
}
|
||||
|
||||
if (input.resultId != null) return killed.length > 0;
|
||||
return Array.from(resultIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一次插件运行加入并发池。返回的 Promise 在子进程退出时 resolve(无论成功/失败)。
|
||||
* 真实结果状态写在 task_analysis_results.resultData / status 中,由前端轮询消费。
|
||||
|
||||
@@ -176,6 +176,27 @@ function readResultJson(workDir: string): PluginResult | null {
|
||||
}
|
||||
}
|
||||
|
||||
const DEMO_FLOW_RESULT_DATA_KEYS = [
|
||||
'demoFlowRunId',
|
||||
'demoFlowRole',
|
||||
'demoFlowIndex',
|
||||
'demoFlowTotal',
|
||||
'demoFlowSourcePointCloudFileId',
|
||||
'demoFlowCadFileId',
|
||||
] as const;
|
||||
|
||||
async function readDemoFlowResultData(resultId: number): Promise<Record<string, unknown>> {
|
||||
const row = await db.getTaskAnalysisResultById(resultId);
|
||||
const existing = (row?.resultData as Record<string, unknown> | null) ?? {};
|
||||
const picked: Record<string, unknown> = {};
|
||||
for (const key of DEMO_FLOW_RESULT_DATA_KEYS) {
|
||||
if (existing[key] !== undefined) {
|
||||
picked[key] = existing[key];
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
async function runProcessRuntime(
|
||||
manifest: PluginManifest,
|
||||
workDir: string,
|
||||
@@ -286,12 +307,14 @@ export async function runAlgorithmJob(
|
||||
): Promise<void> {
|
||||
const manifest = getPlugin(pluginId);
|
||||
if (!manifest) {
|
||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||
await db.updateTaskAnalysisResult(resultId, {
|
||||
status: 'error',
|
||||
resultData: {
|
||||
kind: 'plugin',
|
||||
pluginId,
|
||||
error: `未注册的插件:${pluginId}`,
|
||||
...demoFlowResultData,
|
||||
} as PluginResultData,
|
||||
});
|
||||
return;
|
||||
@@ -333,6 +356,7 @@ export async function runAlgorithmJob(
|
||||
? 'completed'
|
||||
: 'error';
|
||||
|
||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||
const resultData: PluginResultData = {
|
||||
kind: 'plugin',
|
||||
pluginId,
|
||||
@@ -351,6 +375,7 @@ export async function runAlgorithmJob(
|
||||
error: finalStatus === 'error'
|
||||
? result?.message ?? `子进程退出码 ${exitCode}`
|
||||
: undefined,
|
||||
...demoFlowResultData,
|
||||
};
|
||||
|
||||
await db.updateTaskAnalysisResult(resultId, {
|
||||
@@ -404,12 +429,14 @@ export async function runAlgorithmJob(
|
||||
} catch (err) {
|
||||
console.error('[AlgorithmJobRunner]', err);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const demoFlowResultData = await readDemoFlowResultData(resultId);
|
||||
await db.updateTaskAnalysisResult(resultId, {
|
||||
status: 'error',
|
||||
resultData: {
|
||||
kind: 'plugin',
|
||||
pluginId,
|
||||
error: message,
|
||||
...demoFlowResultData,
|
||||
} as PluginResultData,
|
||||
});
|
||||
}
|
||||
|
||||
154
server/analysisJobControl.ts
Normal file
154
server/analysisJobControl.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { ChildProcess, execFileSync } from 'node:child_process';
|
||||
|
||||
export type LegacyAnalysisKind = 'smooth_heatmap' | 'assembly_inspection' | 'obj_deviation';
|
||||
|
||||
interface ActiveLegacyJob {
|
||||
resultId: number;
|
||||
taskId: number;
|
||||
kind: LegacyAnalysisKind;
|
||||
child: ChildProcess;
|
||||
}
|
||||
|
||||
interface ProcessRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
const activeLegacyJobs = new Map<number, ActiveLegacyJob>();
|
||||
|
||||
export function registerLegacyAnalysisChild(
|
||||
resultId: number,
|
||||
taskId: number,
|
||||
kind: LegacyAnalysisKind,
|
||||
child: ChildProcess,
|
||||
): void {
|
||||
activeLegacyJobs.set(resultId, { resultId, taskId, kind, child });
|
||||
child.once('exit', () => {
|
||||
activeLegacyJobs.delete(resultId);
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelLegacyAnalysisJob(resultId: number): boolean {
|
||||
const job = activeLegacyJobs.get(resultId);
|
||||
let cancelled = false;
|
||||
if (job) {
|
||||
activeLegacyJobs.delete(resultId);
|
||||
try {
|
||||
job.child.kill('SIGKILL');
|
||||
cancelled = true;
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
return killOrphanLegacyAnalysisProcesses({ resultId }) || cancelled;
|
||||
}
|
||||
|
||||
export function cancelLegacyAnalysisJobsByTask(taskId: number): number[] {
|
||||
const cancelled = new Set<number>();
|
||||
for (const [resultId, job] of Array.from(activeLegacyJobs.entries())) {
|
||||
if (job.taskId !== taskId) continue;
|
||||
activeLegacyJobs.delete(resultId);
|
||||
try {
|
||||
job.child.kill('SIGKILL');
|
||||
cancelled.add(resultId);
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
for (const resultId of killOrphanLegacyAnalysisProcesses({ taskId })) {
|
||||
cancelled.add(resultId);
|
||||
}
|
||||
return Array.from(cancelled);
|
||||
}
|
||||
|
||||
function listProcesses(): ProcessRow[] {
|
||||
try {
|
||||
const output = execFileSync('ps', ['-eo', 'pid=,ppid=,command='], {
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
command: match[3],
|
||||
};
|
||||
})
|
||||
.filter((row): row is ProcessRow => row != null && Number.isFinite(row.pid) && Number.isFinite(row.ppid));
|
||||
} catch (err) {
|
||||
console.warn('[AnalysisJobControl] 无法列出进程,跳过 legacy 孤儿进程清理:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function collectDescendants(processes: ProcessRow[], rootPids: Set<number>): Set<number> {
|
||||
const all = new Set(rootPids);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const proc of processes) {
|
||||
if (!all.has(proc.pid) && all.has(proc.ppid)) {
|
||||
all.add(proc.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function killPids(pids: number[]): number[] {
|
||||
const killed: number[] = [];
|
||||
for (const pid of pids) {
|
||||
if (pid <= 1 || pid === process.pid) continue;
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
killed.push(pid);
|
||||
} catch {
|
||||
// Process may already have exited.
|
||||
}
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
function runnerRegex(): RegExp {
|
||||
return /(smoothHeatmapJobRunner|assemblyInspectionJobRunner|deviationHeatmapJobRunner)\.(?:ts|js)\s+(\d+)\s+(\d+)\b/;
|
||||
}
|
||||
|
||||
function killOrphanLegacyAnalysisProcesses(input: { resultId: number }): boolean;
|
||||
function killOrphanLegacyAnalysisProcesses(input: { taskId: number }): number[];
|
||||
function killOrphanLegacyAnalysisProcesses(input: { resultId?: number; taskId?: number }): boolean | number[] {
|
||||
const processes = listProcesses();
|
||||
const roots = new Set<number>();
|
||||
const resultIds = new Set<number>();
|
||||
|
||||
for (const proc of processes) {
|
||||
const match = proc.command.match(runnerRegex());
|
||||
if (!match) continue;
|
||||
const resultId = Number(match[2]);
|
||||
const taskId = Number(match[3]);
|
||||
if (input.resultId != null && resultId === input.resultId) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(resultId);
|
||||
}
|
||||
if (input.taskId != null && taskId === input.taskId) {
|
||||
roots.add(proc.pid);
|
||||
resultIds.add(resultId);
|
||||
}
|
||||
}
|
||||
|
||||
const targets = Array.from(collectDescendants(processes, roots)).sort((a, b) => b - a);
|
||||
const killed = killPids(targets);
|
||||
if (killed.length > 0) {
|
||||
console.warn(`[AnalysisJobControl] 已清理 legacy 分析进程 pid=${killed.join(', ')}`);
|
||||
}
|
||||
|
||||
if (input.resultId != null) return killed.length > 0;
|
||||
return Array.from(resultIds);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
||||
import type { SampledPoint } from './pointcloudSample.js';
|
||||
import { runStepParseMvp, stepParseCacheDir } from './stepParse.js';
|
||||
import type { StepManifest } from './stepParse.js';
|
||||
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||
@@ -42,6 +43,7 @@ export function enqueueAssemblyInspectionJob(
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||
});
|
||||
registerLegacyAnalysisChild(resultId, taskId, 'assembly_inspection', child);
|
||||
child.on('error', (err) => console.error('[AssemblyInspection] fork 失败:', err));
|
||||
child.on('exit', (code, signal) => {
|
||||
if (code !== 0 && code != null) {
|
||||
|
||||
63
server/db.ts
63
server/db.ts
@@ -1,4 +1,4 @@
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { eq, desc, inArray, sql, and } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import {
|
||||
@@ -342,19 +342,73 @@ export async function createInspectionTask(data: InsertInspectionTask): Promise<
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise<InspectionTask | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const [row] = await db.select().from(inspectionTasks)
|
||||
.where(and(eq(inspectionTasks.modelId, modelId), eq(inspectionTasks.name, name)))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listInspectionTasksByModel(modelId: number): Promise<InspectionTask[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(inspectionTasks)
|
||||
const tasks = await db.select().from(inspectionTasks)
|
||||
.where(eq(inspectionTasks.modelId, modelId))
|
||||
.orderBy(desc(inspectionTasks.createdAt));
|
||||
return withComputedInspectionTaskStatuses(tasks);
|
||||
}
|
||||
|
||||
export async function getInspectionTaskById(id: number): Promise<InspectionTask | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id));
|
||||
return row;
|
||||
if (!row) return undefined;
|
||||
const [task] = await withComputedInspectionTaskStatuses([row]);
|
||||
return task;
|
||||
}
|
||||
|
||||
type InspectionTaskStatus = InspectionTask["status"];
|
||||
type AnalysisStatus = TaskAnalysisResult["status"];
|
||||
|
||||
function summarizeInspectionTaskStatus(
|
||||
fallback: InspectionTaskStatus,
|
||||
analysisStatuses: AnalysisStatus[],
|
||||
): InspectionTaskStatus {
|
||||
if (analysisStatuses.length === 0) return fallback;
|
||||
if (analysisStatuses.some(status => status === "processing" || status === "pending")) {
|
||||
return "processing";
|
||||
}
|
||||
if (analysisStatuses.some(status => status === "error")) return "error";
|
||||
if (analysisStatuses.some(status => status === "completed")) return "completed";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function withComputedInspectionTaskStatuses(tasks: InspectionTask[]): Promise<InspectionTask[]> {
|
||||
if (tasks.length === 0) return tasks;
|
||||
const db = await getDb();
|
||||
if (!db) return tasks;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
taskId: taskAnalysisResults.taskId,
|
||||
status: taskAnalysisResults.status,
|
||||
})
|
||||
.from(taskAnalysisResults)
|
||||
.where(inArray(taskAnalysisResults.taskId, tasks.map(task => task.id)));
|
||||
|
||||
const statusesByTask = new Map<number, AnalysisStatus[]>();
|
||||
for (const row of rows) {
|
||||
const statuses = statusesByTask.get(row.taskId) ?? [];
|
||||
statuses.push(row.status);
|
||||
statusesByTask.set(row.taskId, statuses);
|
||||
}
|
||||
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
status: summarizeInspectionTaskStatus(task.status, statusesByTask.get(task.id) ?? []),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateInspectionTask(id: number, data: Partial<InsertInspectionTask>): Promise<void> {
|
||||
@@ -366,6 +420,9 @@ export async function updateInspectionTask(id: number, data: Partial<InsertInspe
|
||||
export async function deleteInspectionTask(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.taskId, id));
|
||||
await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.taskId, id));
|
||||
await db.delete(taskCadFiles).where(eq(taskCadFiles.taskId, id));
|
||||
await db.delete(inspectionTasks).where(eq(inspectionTasks.id, id));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
||||
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
||||
import { deviationGpuEnabled, tryRunDeviationGpu } from './deviationGpuCompute.js';
|
||||
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||
@@ -41,6 +42,7 @@ export function enqueueDeviationHeatmapJob(resultId: number, taskId: number, tot
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||
});
|
||||
registerLegacyAnalysisChild(resultId, taskId, 'obj_deviation', child);
|
||||
child.on('error', (err) => {
|
||||
console.error('[DeviationHeatmapJob] fork 失败:', err);
|
||||
});
|
||||
|
||||
119
server/inspectionTask.test.ts
Normal file
119
server/inspectionTask.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import * as db from "./db";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
|
||||
vi.mock("./db", () => ({
|
||||
findInspectionTaskByModelAndName: vi.fn(async () => undefined),
|
||||
createInspectionTask: vi.fn(async (data) => ({
|
||||
id: 1,
|
||||
modelId: data.modelId,
|
||||
name: data.name,
|
||||
inspector: data.inspector ?? null,
|
||||
notes: data.notes ?? null,
|
||||
status: data.status,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
function createAuthContext(): TrpcContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: 1,
|
||||
openId: "test-user",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
loginMethod: "manus",
|
||||
role: "user",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
} as TrpcContext["req"],
|
||||
res: {
|
||||
clearCookie: vi.fn(),
|
||||
} as unknown as TrpcContext["res"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("inspection task router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("trims name and creates a task in created status", async () => {
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
const result = await caller.inspectionTask.create({
|
||||
modelId: 1,
|
||||
name: " 首检任务 ",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("created");
|
||||
expect(db.findInspectionTaskByModelAndName).toHaveBeenCalledWith(1, "首检任务");
|
||||
expect(db.createInspectionTask).toHaveBeenCalledWith({
|
||||
modelId: 1,
|
||||
name: "首检任务",
|
||||
inspector: null,
|
||||
notes: null,
|
||||
status: "created",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns CONFLICT when the same model already has the task name", async () => {
|
||||
vi.mocked(db.findInspectionTaskByModelAndName).mockResolvedValueOnce({
|
||||
id: 1,
|
||||
modelId: 1,
|
||||
name: "首检任务",
|
||||
inspector: null,
|
||||
notes: null,
|
||||
status: "created",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.inspectionTask.create({
|
||||
modelId: 1,
|
||||
name: "首检任务",
|
||||
})).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "该车型下已存在同名检测任务。",
|
||||
});
|
||||
|
||||
expect(db.createInspectionTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns CONFLICT when the database unique key rejects a concurrent duplicate", async () => {
|
||||
vi.mocked(db.createInspectionTask).mockRejectedValueOnce({
|
||||
code: "ER_DUP_ENTRY",
|
||||
errno: 1062,
|
||||
message: "Duplicate entry '1-首检任务' for key 'uk_inspection_tasks_model_name'",
|
||||
});
|
||||
|
||||
const ctx = createAuthContext();
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.inspectionTask.create({
|
||||
modelId: 1,
|
||||
name: "首检任务",
|
||||
})).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
message: "该车型下已存在同名检测任务。",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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";
|
||||
@@ -13,6 +14,22 @@ 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";
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || "/data";
|
||||
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
||||
|
||||
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;
|
||||
@@ -60,6 +77,231 @@ async function validateRegistrationRunInput(input: {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -285,18 +527,30 @@ export const appRouter = router({
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
modelId: z.number(),
|
||||
name: z.string().min(1).max(255),
|
||||
name: z.string().trim().min(1).max(255),
|
||||
inspector: z.string().max(100).optional(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
return db.createInspectionTask({
|
||||
modelId: input.modelId,
|
||||
name: input.name,
|
||||
inspector: input.inspector ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: "pending",
|
||||
});
|
||||
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
|
||||
@@ -305,7 +559,7 @@ export const appRouter = router({
|
||||
name: z.string().max(255).optional(),
|
||||
inspector: z.string().max(100).optional(),
|
||||
notes: z.string().optional(),
|
||||
status: z.enum(["pending", "processing", "completed", "error"]).optional(),
|
||||
status: z.enum(["created", "pending", "processing", "completed", "error"]).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
@@ -316,12 +570,19 @@ export const appRouter = router({
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
await db.deleteInspectionTask(input.id);
|
||||
// 取消该任务下所有正在运行或排队的算法 job
|
||||
const cancelled = cancelAlgorithmJobsByTask(input.id);
|
||||
// 取消该任务下所有正在运行或排队的算法 job,再删除任务记录。
|
||||
const cancelled = [
|
||||
...cancelAlgorithmJobsByTask(input.id),
|
||||
...cancelLegacyAnalysisJobsByTask(input.id),
|
||||
];
|
||||
if (cancelled.length > 0) {
|
||||
console.log(`[routers] 删除任务 ${input.id},取消 ${cancelled.length} 个算法 job:`, cancelled);
|
||||
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 };
|
||||
}),
|
||||
}),
|
||||
@@ -524,11 +785,25 @@ export const appRouter = router({
|
||||
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 }) => {
|
||||
// 先取消正在运行或排队的 job,再删数据库记录
|
||||
cancelAlgorithmJob(input.id);
|
||||
cancelLegacyAnalysisJob(input.id);
|
||||
await db.deleteTaskAnalysisResult(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -600,7 +875,7 @@ export const appRouter = router({
|
||||
record.id,
|
||||
input.taskId,
|
||||
manifest.id,
|
||||
input.params ?? {},
|
||||
mergePluginParams(manifest, input.params),
|
||||
input.pointCloudFileIds,
|
||||
input.cadFileIds,
|
||||
);
|
||||
@@ -672,7 +947,7 @@ export const appRouter = router({
|
||||
record.id,
|
||||
input.taskId,
|
||||
manifest.id,
|
||||
job.params ?? {},
|
||||
mergePluginParams(manifest, job.params),
|
||||
input.pointCloudFileIds,
|
||||
input.cadFileIds,
|
||||
);
|
||||
@@ -680,6 +955,109 @@ export const appRouter = router({
|
||||
}
|
||||
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, "用户停止一键全流程演示");
|
||||
return {
|
||||
...result,
|
||||
scheduler: algorithmJobStats(),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './cadHeatmapCompute.js';
|
||||
import { pickCadFileForHeatmap } from './cadFilePick.js';
|
||||
import { sampleTaskPointclouds } from './sampleTaskPointclouds.js';
|
||||
import { registerLegacyAnalysisChild } from './analysisJobControl.js';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||
const TASKS_DIR = path.join(DATA_DIR, 'tasks');
|
||||
@@ -49,6 +50,7 @@ export function enqueueSmoothHeatmapJob(resultId: number, taskId: number, totalP
|
||||
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||
execArgv: isProd ? [] : ['--import', 'tsx'],
|
||||
});
|
||||
registerLegacyAnalysisChild(resultId, taskId, 'smooth_heatmap', child);
|
||||
child.on('error', (err) => {
|
||||
console.error('[SmoothHeatmapJob] fork 失败:', err);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user