import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import type { ResultSetHeader } from "mysql2"; import { inspectPointcloudMetadata } from "./pointcloudMetadata.js"; import { users, InsertUser, vehicleSeries, InsertVehicleSeries, VehicleSeries, vehicleModels, InsertVehicleModel, VehicleModel, inspectionObjects, InsertInspectionObject, InspectionObject, modelCadFiles, InsertModelCadFile, ModelCadFile, modelCadInspectionObjects, modelPointcloudFiles, InsertModelPointcloudFile, ModelPointcloudFile, taskCadFiles, InsertTaskCadFile, TaskCadFile, inspectionTasks, InsertInspectionTask, InspectionTask, taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile, taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult, } from "../drizzle/schema"; let _db: ReturnType | null = null; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); /** drizzle mysql2 insert resolves to mysql2 ResultSetHeader with insertId (not result[0].insertId). */ function getMysqlInsertId(result: unknown): number { const r = result as ResultSetHeader & { 0?: ResultSetHeader }; if (typeof r.insertId === "number" && r.insertId > 0) return r.insertId; if (r[0] && typeof r[0].insertId === "number") return r[0].insertId; throw new Error("insertId missing from insert result"); } /** * Clears the cached pool so the next getDb() creates a new connection. * Call after ECONNREFUSED / PROTOCOL_CONNECTION_LOST so retries can recover. */ export function invalidateDb() { _db = null; } /** * Returns the drizzle DB instance, creating it if needed. * Retries the initial TCP probe while MySQL is still starting (common in Docker). */ export async function getDb() { if (_db) return _db; if (!process.env.DATABASE_URL) return null; const maxAttempts = 40; const delayMs = 500; // One drizzle instance / pool; retry execute only (avoids leaking a pool per attempt). const instance = drizzle(process.env.DATABASE_URL); for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { await instance.execute(sql`SELECT 1`); _db = instance; if (attempt > 1) { console.log(`[Database] Connected after ${attempt} attempt(s).`); } return _db; } catch (error: any) { const msg = error?.message ?? String(error); const retriable = error?.code === "ECONNREFUSED" || error?.code === "ETIMEDOUT" || error?.code === "ENOTFOUND" || msg.includes("connect"); if (attempt < maxAttempts && retriable) { if (attempt === 1 || attempt % 5 === 0) { console.warn(`[Database] Waiting for MySQL... (${attempt}/${maxAttempts}) ${msg}`); } await sleep(delayMs); continue; } console.error("[Database] Giving up:", msg); _db = null; return null; } } return null; } // ─── 用户 ───────────────────────────────────────────────────────────────────── export async function getUserById(id: number) { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(users).where(eq(users.id, id)).limit(1); return result[0]; } export async function getUserByUsername(username: string) { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(users).where(eq(users.username, username)).limit(1); return result[0]; } export async function listUsers(search?: string) { const db = await getDb(); if (!db) return []; const term = search?.trim(); if (!term) return db.select().from(users).orderBy(desc(users.createdAt)); const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`; return db.select().from(users) .where(or(like(users.username, pattern), like(users.name, pattern), like(users.email, pattern))) .orderBy(desc(users.createdAt)); } export async function createUser(data: InsertUser) { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(users).values(data); return getUserById(getMysqlInsertId(result)); } export async function updateUser(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(users).set(data).where(eq(users.id, id)); return getUserById(id); } export async function incrementUserSessionVersion(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(users) .set({ sessionVersion: sql`${users.sessionVersion} + 1` }) .where(eq(users.id, id)); } export async function deleteUser(id: number) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(users).where(eq(users.id, id)); } export async function countActiveSuperAdmins() { const db = await getDb(); if (!db) throw new Error("Database not available"); const [row] = await db.select({ count: sql`count(*)` }).from(users) .where(and(eq(users.role, "super_admin"), eq(users.isActive, 1))); return Number(row?.count ?? 0); } // ─── 车系 ───────────────────────────────────────────────────────────────────── export async function createVehicleSeries(data: InsertVehicleSeries): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(vehicleSeries).values(data); const [row] = await db.select().from(vehicleSeries).where(eq(vehicleSeries.id, getMysqlInsertId(result))); return row; } export async function listVehicleSeries(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(vehicleSeries).orderBy(desc(vehicleSeries.createdAt)); } export async function updateVehicleSeries(id: number, data: Partial): Promise { const db = await getDb(); if (!db) return; await db.update(vehicleSeries).set(data).where(eq(vehicleSeries.id, id)); } export async function deleteVehicleSeries(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(vehicleSeries).where(eq(vehicleSeries.id, id)); } // ─── 车型 ───────────────────────────────────────────────────────────────────── export async function createVehicleModel(data: InsertVehicleModel): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(vehicleModels).values(data); const [row] = await db.select().from(vehicleModels).where(eq(vehicleModels.id, getMysqlInsertId(result))); return row; } export async function listVehicleModelsBySeries(seriesId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(vehicleModels) .where(eq(vehicleModels.seriesId, seriesId)) .orderBy(desc(vehicleModels.createdAt)); } export async function getVehicleModelById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(vehicleModels).where(eq(vehicleModels.id, id)); return row; } export async function updateVehicleModel(id: number, data: Partial): Promise { const db = await getDb(); if (!db) return; await db.update(vehicleModels).set(data).where(eq(vehicleModels.id, id)); } export async function deleteVehicleModel(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(vehicleModels).where(eq(vehicleModels.id, id)); } // ─── 检测对象与默认输入方案 ────────────────────────────────────────────────── export type InspectionObjectWithConfiguration = InspectionObject & { defaultPointcloudName: string | null; defaultCadName: string | null; configurationState: "missing_pointcloud" | "missing_cad" | "ready_draft" | "active"; taskReferenceCount: number; pointcloudReferenceCount: number; cadReferenceCount: number; canDelete: boolean; }; export type InspectionObjectReferenceCounts = { taskReferenceCount: number; pointcloudReferenceCount: number; cadReferenceCount: number; }; export class InspectionObjectReferencedError extends Error { constructor(public readonly references: InspectionObjectReferenceCounts) { const parts = [ references.taskReferenceCount > 0 ? `任务 ${references.taskReferenceCount} 个` : null, references.pointcloudReferenceCount > 0 ? `点云 ${references.pointcloudReferenceCount} 个` : null, references.cadReferenceCount > 0 ? `CAD ${references.cadReferenceCount} 个` : null, ].filter(Boolean); super(`检测对象仍被以下数据引用:${parts.join("、")}。请先处理关联数据后再删除。`); this.name = "InspectionObjectReferencedError"; } } export function assertInspectionObjectDeletable(references: InspectionObjectReferenceCounts): void { if (references.taskReferenceCount + references.pointcloudReferenceCount + references.cadReferenceCount > 0) { throw new InspectionObjectReferencedError(references); } } export async function listInspectionObjects(modelId: number, activeOnly = false): Promise { const db = await getDb(); if (!db) return []; const conditions = [eq(inspectionObjects.modelId, modelId)]; if (activeOnly) conditions.push(eq(inspectionObjects.status, "active")); const rows = await db.select().from(inspectionObjects).where(and(...conditions)).orderBy(inspectionObjects.createdAt); if (rows.length === 0) return []; const objectIds = rows.map(row => row.id); const pointclouds = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.modelId, modelId)); const cadFiles = await db.select().from(modelCadFiles).where(eq(modelCadFiles.modelId, modelId)); const taskReferences = await db.select({ inspectionObjectId: inspectionTasks.inspectionObjectId, count: sql`count(*)`, }).from(inspectionTasks).where(inArray(inspectionTasks.inspectionObjectId, objectIds)).groupBy(inspectionTasks.inspectionObjectId); const cadReferences = await db.select({ inspectionObjectId: modelCadInspectionObjects.inspectionObjectId, count: sql`count(*)`, }).from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.inspectionObjectId, objectIds)).groupBy(modelCadInspectionObjects.inspectionObjectId); const pcById = new Map(pointclouds.map(row => [row.id, row])); const cadById = new Map(cadFiles.map(row => [row.id, row])); const taskCountByObjectId = new Map(taskReferences.map(row => [row.inspectionObjectId, Number(row.count)])); const cadCountByObjectId = new Map(cadReferences.map(row => [row.inspectionObjectId, Number(row.count)])); const pointcloudCountByObjectId = new Map(); for (const pointcloud of pointclouds) { if (pointcloud.inspectionObjectId != null) { pointcloudCountByObjectId.set(pointcloud.inspectionObjectId, (pointcloudCountByObjectId.get(pointcloud.inspectionObjectId) ?? 0) + 1); } } return rows.map(row => { const pc = row.defaultModelPointcloudId ? pcById.get(row.defaultModelPointcloudId) : undefined; const cad = row.defaultModelCadFileId ? cadById.get(row.defaultModelCadFileId) : undefined; const hasPc = pc?.inspectionObjectId === row.id && pc.status === "available"; const hasCad = cad?.status === "available"; const taskReferenceCount = taskCountByObjectId.get(row.id) ?? 0; const pointcloudReferenceCount = pointcloudCountByObjectId.get(row.id) ?? 0; const cadReferenceCount = cadCountByObjectId.get(row.id) ?? 0; return { ...row, defaultPointcloudName: pc?.name ?? null, defaultCadName: cad?.name ?? null, configurationState: !hasPc ? "missing_pointcloud" : !hasCad ? "missing_cad" : row.status === "active" ? "active" : "ready_draft", taskReferenceCount, pointcloudReferenceCount, cadReferenceCount, canDelete: taskReferenceCount + pointcloudReferenceCount + cadReferenceCount === 0, }; }); } export async function getInspectionObjectById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(inspectionObjects).where(eq(inspectionObjects.id, id)); return row; } export async function createInspectionObject(data: InsertInspectionObject): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(inspectionObjects).values(data); return (await getInspectionObjectById(getMysqlInsertId(result)))!; } export async function updateInspectionObject(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(inspectionObjects).set(data).where(eq(inspectionObjects.id, id)); return (await getInspectionObjectById(id))!; } export async function deleteInspectionObject(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.transaction(async tx => { const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(eq(inspectionObjects.id, id)).limit(1).for("update"); if (!object) throw new Error("检测对象不存在"); const taskRows = await tx.select({ id: inspectionTasks.id }).from(inspectionTasks) .where(eq(inspectionTasks.inspectionObjectId, id)).for("update"); const pointcloudRows = await tx.select({ id: modelPointcloudFiles.id }).from(modelPointcloudFiles) .where(eq(modelPointcloudFiles.inspectionObjectId, id)).for("update"); const cadRows = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects) .where(eq(modelCadInspectionObjects.inspectionObjectId, id)).for("update"); assertInspectionObjectDeletable({ taskReferenceCount: taskRows.length, pointcloudReferenceCount: pointcloudRows.length, cadReferenceCount: cadRows.length, }); await tx.delete(inspectionObjects).where(eq(inspectionObjects.id, id)); }); } export async function cadAppliesToInspectionObject(cadFileId: number, inspectionObjectId: number): Promise { const db = await getDb(); if (!db) return false; const [row] = await db.select().from(modelCadInspectionObjects).where(and( eq(modelCadInspectionObjects.cadFileId, cadFileId), eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId), )).limit(1); return Boolean(row); } export async function setCadInspectionObjects(cadFileId: number, inspectionObjectIds: number[]): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.transaction(async tx => { const lockedObjects = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(inArray(inspectionObjects.id, inspectionObjectIds)).for("share"); if (lockedObjects.length !== new Set(inspectionObjectIds).size) throw new Error("Inspection object not found"); await tx.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, cadFileId)); if (inspectionObjectIds.length > 0) { await tx.insert(modelCadInspectionObjects).values(inspectionObjectIds.map(inspectionObjectId => ({ cadFileId, inspectionObjectId }))); } }); } export async function listCadInspectionObjectIds(cadFileIds: number[]): Promise> { const db = await getDb(); if (!db || cadFileIds.length === 0) return {}; const rows = await db.select().from(modelCadInspectionObjects).where(inArray(modelCadInspectionObjects.cadFileId, cadFileIds)); return rows.reduce>((result, row) => { (result[row.cadFileId] ??= []).push(row.inspectionObjectId); return result; }, {}); } export async function configureInspectionObject(input: { id: number; defaultModelPointcloudId: number; defaultModelCadFileId: number; active: boolean; }): Promise { const object = await getInspectionObjectById(input.id); const pc = await getModelPointcloudFileById(input.defaultModelPointcloudId); const cad = await getModelCadFileById(input.defaultModelCadFileId); if (!object || !pc || !cad) throw new Error("Configuration resource not found"); if (pc.modelId !== object.modelId || pc.inspectionObjectId !== object.id || pc.status !== "available") { throw new Error("Pointcloud is incompatible with inspection object"); } if (cad.modelId !== object.modelId || cad.status !== "available" || !await cadAppliesToInspectionObject(cad.id, object.id)) { throw new Error("CAD is incompatible with inspection object"); } return updateInspectionObject(object.id, { defaultModelPointcloudId: pc.id, defaultModelCadFileId: cad.id, status: input.active ? "active" : "draft", }); } // ─── 车型 CAD 文件 ───────────────────────────────────────────────────────────── export async function createModelCadFile(data: InsertModelCadFile): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(modelCadFiles).values(data); const [row] = await db.select().from(modelCadFiles).where(eq(modelCadFiles.id, getMysqlInsertId(result))); return row; } export async function listModelCadFiles(modelId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(modelCadFiles) .where(eq(modelCadFiles.modelId, modelId)) .orderBy(desc(modelCadFiles.createdAt)); } export async function listModelCadFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise { const db = await getDb(); if (!db) return []; return db.select({ id: modelCadFiles.id, modelId: modelCadFiles.modelId, name: modelCadFiles.name, format: modelCadFiles.format, fileSize: modelCadFiles.fileSize, filePath: modelCadFiles.filePath, contentScope: modelCadFiles.contentScope, status: modelCadFiles.status, createdAt: modelCadFiles.createdAt, }).from(modelCadFiles).innerJoin(modelCadInspectionObjects, eq(modelCadInspectionObjects.cadFileId, modelCadFiles.id)) .where(and(eq(modelCadFiles.modelId, modelId), eq(modelCadFiles.status, "available"), eq(modelCadInspectionObjects.inspectionObjectId, inspectionObjectId))) .orderBy(desc(modelCadFiles.createdAt)); } export async function getModelCadFileById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(modelCadFiles).where(eq(modelCadFiles.id, id)); return row; } export async function deleteModelCadFile(id: number): Promise { const db = await getDb(); if (!db) return; const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(eq(inspectionObjects.defaultModelCadFileId, id)).limit(1); if (defaultUse) throw new Error("Resource is used as default input"); const [historicalUse] = await db.select({ id: taskCadFiles.id }).from(taskCadFiles) .where(eq(taskCadFiles.sourceModelCadFileId, id)).limit(1); if (historicalUse) { await db.update(modelCadFiles).set({ status: "disabled" }).where(eq(modelCadFiles.id, id)); return; } await db.delete(modelCadInspectionObjects).where(eq(modelCadInspectionObjects.cadFileId, id)); await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id)); } // ─── 车型点云文件 ───────────────────────────────────────────────────────────── export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); return db.transaction(async tx => { if (data.inspectionObjectId != null) { const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share"); if (!object) throw new Error("Inspection object not found"); } const result = await tx.insert(modelPointcloudFiles).values(data); const [row] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, getMysqlInsertId(result))); return row; }); } const modelMetadataBackfills = new Map>(); async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise { if (row.metadataStatus !== "pending") return row; const existing = modelMetadataBackfills.get(row.id); if (existing) return existing; const backfill = (async () => { const db = await getDb(); if (!db) return row; const metadata = inspectPointcloudMetadata(row.filePath, row.name); await db.update(modelPointcloudFiles) .set(metadata) .where(and( eq(modelPointcloudFiles.id, row.id), eq(modelPointcloudFiles.metadataStatus, "pending"), )); return { ...row, ...metadata }; })(); modelMetadataBackfills.set(row.id, backfill); try { return await backfill; } finally { modelMetadataBackfills.delete(row.id); } } export async function listModelPointcloudFiles(modelId: number): Promise { const db = await getDb(); if (!db) return []; const rows = await db.select().from(modelPointcloudFiles) .where(eq(modelPointcloudFiles.modelId, modelId)) .orderBy(desc(modelPointcloudFiles.createdAt)); return Promise.all(rows.map(backfillModelPointcloudMetadata)); } export async function listModelPointcloudFilesForInspectionObject(modelId: number, inspectionObjectId: number): Promise { const rows = await listModelPointcloudFiles(modelId); return rows.filter(row => row.inspectionObjectId === inspectionObjectId && row.status === "available"); } export async function assignModelPointcloudInspectionObject(id: number, inspectionObjectId: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); return db.transaction(async tx => { const [object] = await tx.select().from(inspectionObjects) .where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share"); const [file] = await tx.select().from(modelPointcloudFiles) .where(eq(modelPointcloudFiles.id, id)).limit(1).for("update"); if (!file || !object || file.modelId !== object.modelId) throw new Error("Inspection object does not belong to file model"); await tx.update(modelPointcloudFiles).set({ inspectionObjectId }).where(eq(modelPointcloudFiles.id, id)); const [updated] = await tx.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id)); return updated; }); } export async function getModelPointcloudFileById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id)); return row; } export async function findModelPointcloudFileByModelAndName( modelId: number, name: string, excludeId?: number, ): Promise { const db = await getDb(); if (!db) return undefined; const conditions = [ eq(modelPointcloudFiles.modelId, modelId), sql`lower(${modelPointcloudFiles.name}) = lower(${name})`, ]; if (excludeId !== undefined) { conditions.push(sql`${modelPointcloudFiles.id} <> ${excludeId}`); } const [row] = await db.select().from(modelPointcloudFiles).where(and(...conditions)).limit(1); return row; } export async function updateModelPointcloudFileName( id: number, name: string, ): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(modelPointcloudFiles).set({ name }).where(eq(modelPointcloudFiles.id, id)); const row = await getModelPointcloudFileById(id); if (!row) throw new Error("Model pointcloud file disappeared after update"); return row; } export async function deleteModelPointcloudFile(id: number): Promise { const db = await getDb(); if (!db) return; const [defaultUse] = await db.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(eq(inspectionObjects.defaultModelPointcloudId, id)).limit(1); if (defaultUse) throw new Error("Resource is used as default input"); const [historicalUse] = await db.select({ id: taskPointcloudFiles.id }).from(taskPointcloudFiles) .where(eq(taskPointcloudFiles.sourceModelPointcloudId, id)).limit(1); if (historicalUse) { await db.update(modelPointcloudFiles).set({ status: "disabled" }).where(eq(modelPointcloudFiles.id, id)); return; } await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id)); } // ─── 任务 CAD 文件 ───────────────────────────────────────────────────────────── export async function createTaskCadFile(data: InsertTaskCadFile): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(taskCadFiles).values(data); const [row] = await db.select().from(taskCadFiles).where(eq(taskCadFiles.id, getMysqlInsertId(result))); return row; } export async function listTaskCadFiles(taskId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(taskCadFiles) .where(eq(taskCadFiles.taskId, taskId)) .orderBy(taskCadFiles.createdAt); } export async function getTaskCadFileById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(taskCadFiles).where(eq(taskCadFiles.id, id)); return row; } export async function deleteTaskCadFile(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(taskCadFiles).where(eq(taskCadFiles.id, id)); } export async function linkTaskCadFilesFromModel(taskId: number, modelCadFileIds: number[]): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const task = await getInspectionTaskById(taskId); if (!task) throw new Error("Task not found"); const existing = await listTaskCadFiles(taskId); const existingSourceIds = new Set( existing .map((row) => row.sourceModelCadFileId) .filter((id): id is number => typeof id === "number"), ); const inserted: TaskCadFile[] = []; for (const id of modelCadFileIds) { if (existingSourceIds.has(id)) continue; const source = await getModelCadFileById(id); if (!source) throw new Error("Model CAD file not found"); if (source.modelId !== task.modelId) { throw new Error("Cannot link CAD file from another model"); } if (!task.inspectionObjectId || source.status !== "available" || !await cadAppliesToInspectionObject(source.id, task.inspectionObjectId)) { throw new Error("Cannot link CAD file from another inspection object"); } const record = await createTaskCadFile({ taskId, name: source.name, format: source.format, fileSize: source.fileSize, filePath: source.filePath, source: "model_library", sourceModelCadFileId: source.id, } as InsertTaskCadFile); inserted.push(record); existingSourceIds.add(id); } return inserted; } // ─── 检测任务 ───────────────────────────────────────────────────────────────── export async function createInspectionTask(data: InsertInspectionTask): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(inspectionTasks).values(data); const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, getMysqlInsertId(result))); return row; } export async function createInspectionTaskWithDefaults(data: InsertInspectionTask & { inspectionObjectId: number }): Promise<{ task: InspectionTask; pointcloudFile: TaskPointcloudFile; cadFile: TaskCadFile; }> { const db = await getDb(); if (!db) throw new Error("Database not available"); return db.transaction(async tx => { const [object] = await tx.select().from(inspectionObjects) .where(eq(inspectionObjects.id, data.inspectionObjectId)).limit(1).for("share"); if (!object || object.modelId !== data.modelId || object.status !== "active") throw new Error("Inspection object is not active for model"); if (!object.defaultModelPointcloudId || !object.defaultModelCadFileId) throw new Error("Default input scheme is incomplete"); const [pointcloud] = await tx.select().from(modelPointcloudFiles) .where(eq(modelPointcloudFiles.id, object.defaultModelPointcloudId)).limit(1); const [cad] = await tx.select().from(modelCadFiles) .where(eq(modelCadFiles.id, object.defaultModelCadFileId)).limit(1); const [cadApplication] = await tx.select({ cadFileId: modelCadInspectionObjects.cadFileId }).from(modelCadInspectionObjects).where(and( eq(modelCadInspectionObjects.cadFileId, object.defaultModelCadFileId), eq(modelCadInspectionObjects.inspectionObjectId, object.id), )).limit(1); if (!pointcloud || pointcloud.inspectionObjectId !== object.id || pointcloud.status !== "available") throw new Error("Default pointcloud is invalid"); if (!cad || cad.modelId !== object.modelId || cad.status !== "available" || !cadApplication) throw new Error("Default CAD is invalid"); const taskInsert = await tx.insert(inspectionTasks).values(data); const taskId = getMysqlInsertId(taskInsert); const pointcloudInsert = await tx.insert(taskPointcloudFiles).values({ taskId, name: pointcloud.name, fileSize: pointcloud.fileSize, filePath: pointcloud.filePath, source: "model_library", sourceModelPointcloudId: pointcloud.id, metadataStatus: pointcloud.metadataStatus, pointCount: pointcloud.pointCount, pointAttributes: pointcloud.pointAttributes, pointDataFormat: pointcloud.pointDataFormat, bytesPerPoint: pointcloud.bytesPerPoint, metadataError: pointcloud.metadataError, }); const cadInsert = await tx.insert(taskCadFiles).values({ taskId, name: cad.name, format: cad.format, fileSize: cad.fileSize, filePath: cad.filePath, source: "model_library", sourceModelCadFileId: cad.id, }); const [task] = await tx.select().from(inspectionTasks).where(eq(inspectionTasks.id, taskId)); const [pointcloudFile] = await tx.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, getMysqlInsertId(pointcloudInsert))); const [cadFile] = await tx.select().from(taskCadFiles).where(eq(taskCadFiles.id, getMysqlInsertId(cadInsert))); return { task, pointcloudFile, cadFile }; }); } export async function findInspectionTaskByModelAndName(modelId: number, name: string): Promise { 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 { const db = await getDb(); if (!db) return []; 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 { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(inspectionTasks).where(eq(inspectionTasks.id, id)); 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 { 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(); 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): Promise { const db = await getDb(); if (!db) return; await db.update(inspectionTasks).set(data).where(eq(inspectionTasks.id, id)); } export async function classifyInspectionTask(id: number, inspectionObjectId: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.transaction(async tx => { const [object] = await tx.select({ id: inspectionObjects.id }).from(inspectionObjects) .where(eq(inspectionObjects.id, inspectionObjectId)).limit(1).for("share"); if (!object) throw new Error("Inspection object not found"); await tx.update(inspectionTasks).set({ inspectionObjectId }).where(eq(inspectionTasks.id, id)); }); } export async function deleteInspectionTask(id: number): Promise { 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)); } // ─── 任务点云文件 ────────────────────────────────────────────────────────────── export async function createTaskPointcloudFile(data: InsertTaskPointcloudFile): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(taskPointcloudFiles).values(data); const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, getMysqlInsertId(result))); return row; } /** 把配准算法产出的点云注册为任务点云(source='registered')。 * filePath 必须是绝对路径,文件必须已存在于磁盘。 */ export async function createRegisteredPointcloudFile( taskId: number, name: string, filePath: string, sourceResultId: number, sourcePointcloudFileId?: number | null, ): Promise { const fs = await import('node:fs'); const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; const metadata = inspectPointcloudMetadata(filePath, name); return createTaskPointcloudFile({ taskId, name, filePath, fileSize, source: 'registered', sourceResultId, sourcePointcloudFileId: sourcePointcloudFileId ?? null, ...metadata, } as InsertTaskPointcloudFile); } const taskMetadataBackfills = new Map>(); async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise { if (row.metadataStatus !== "pending") return row; const existing = taskMetadataBackfills.get(row.id); if (existing) return existing; const backfill = (async () => { const db = await getDb(); if (!db) return row; const metadata = inspectPointcloudMetadata(row.filePath, row.name); await db.update(taskPointcloudFiles) .set(metadata) .where(and( eq(taskPointcloudFiles.id, row.id), eq(taskPointcloudFiles.metadataStatus, "pending"), )); return { ...row, ...metadata }; })(); taskMetadataBackfills.set(row.id, backfill); try { return await backfill; } finally { taskMetadataBackfills.delete(row.id); } } export async function listTaskPointcloudFiles(taskId: number): Promise { const db = await getDb(); if (!db) return []; const rows = await db.select().from(taskPointcloudFiles) .where(eq(taskPointcloudFiles.taskId, taskId)) .orderBy(taskPointcloudFiles.createdAt); return Promise.all(rows.map(backfillTaskPointcloudMetadata)); } export async function getTaskPointcloudFileById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id)); return row; } export async function findTaskPointcloudFileByTaskAndName( taskId: number, name: string, excludeId?: number, ): Promise { const db = await getDb(); if (!db) return undefined; const conditions = [ eq(taskPointcloudFiles.taskId, taskId), sql`lower(${taskPointcloudFiles.name}) = lower(${name})`, ]; if (excludeId !== undefined) { conditions.push(sql`${taskPointcloudFiles.id} <> ${excludeId}`); } const [row] = await db.select().from(taskPointcloudFiles).where(and(...conditions)).limit(1); return row; } export async function updateTaskPointcloudFileName( id: number, name: string, ): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(taskPointcloudFiles).set({ name }).where(eq(taskPointcloudFiles.id, id)); const row = await getTaskPointcloudFileById(id); if (!row) throw new Error("Task pointcloud file disappeared after update"); return row; } export async function deleteTaskPointcloudFile(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id)); } export async function linkTaskPointcloudFilesFromModel( taskId: number, modelPointcloudFileIds: number[], ): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const task = await getInspectionTaskById(taskId); if (!task) throw new Error("Task not found"); const existing = await listTaskPointcloudFiles(taskId); const existingSourceIds = new Set( existing .map((row) => row.sourceModelPointcloudId) .filter((id): id is number => typeof id === "number"), ); const inserted: TaskPointcloudFile[] = []; for (const id of modelPointcloudFileIds) { if (existingSourceIds.has(id)) continue; const source = await getModelPointcloudFileById(id); if (!source) throw new Error("Model pointcloud file not found"); if (source.modelId !== task.modelId) { throw new Error("Cannot link pointcloud file from another model"); } if (!task.inspectionObjectId || source.inspectionObjectId !== task.inspectionObjectId || source.status !== "available") { throw new Error("Cannot link pointcloud file from another inspection object"); } const record = await createTaskPointcloudFile({ taskId, name: source.name, fileSize: source.fileSize, filePath: source.filePath, source: "model_library", sourceModelPointcloudId: source.id, metadataStatus: source.metadataStatus, pointCount: source.pointCount, pointAttributes: source.pointAttributes, pointDataFormat: source.pointDataFormat, bytesPerPoint: source.bytesPerPoint, metadataError: source.metadataError, } as InsertTaskPointcloudFile); inserted.push(record); existingSourceIds.add(id); } return inserted; } // ─── 任务分析结果 ────────────────────────────────────────────────────────────── export async function createTaskAnalysisResult(data: InsertTaskAnalysisResult): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(taskAnalysisResults).values(data); const [row] = await db.select().from(taskAnalysisResults).where(eq(taskAnalysisResults.id, getMysqlInsertId(result))); return row; } export async function listTaskAnalysisResults(taskId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(taskAnalysisResults) .where(eq(taskAnalysisResults.taskId, taskId)) .orderBy(desc(taskAnalysisResults.createdAt)); } export async function getTaskAnalysisResultById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const [row] = await db.select().from(taskAnalysisResults).where(eq(taskAnalysisResults.id, id)); return row; } export async function updateTaskAnalysisResult(id: number, data: Partial): Promise { const db = await getDb(); if (!db) return; await db.update(taskAnalysisResults).set(data).where(eq(taskAnalysisResults.id, id)); } /** * 非破坏性局部更新 task_analysis_results.resultData。 * 与 updateTaskAnalysisResult(resultData) 不同——后者会把整个 JSON 列覆写, * 适合「最终结果」;本函数把 patch 合并到现有 JSON 上,适合 * 子进程频繁上报进度的场景(例如算法插件 progress / stage)。 * * 实现策略:先读取一次再 set 写回;并发更新时按「最后写入者获胜」处理, * 对进度上报来说足够安全(覆盖丢失最多 1~2 个心跳)。 */ export async function patchTaskAnalysisResultData( id: number, patch: Record, ): Promise { const db = await getDb(); if (!db) return; const existing = await getTaskAnalysisResultById(id); if (!existing) return; const prev = (existing.resultData as Record | null) ?? {}; const merged: Record = { ...prev }; for (const [k, v] of Object.entries(patch)) { if (v === undefined) continue; merged[k] = v; } await db .update(taskAnalysisResults) .set({ resultData: merged }) .where(eq(taskAnalysisResults.id, id)); } export async function deleteTaskAnalysisResult(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id)); } /** * 删除分析结果及其派生的已配准点云记录。 * 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。 */ export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.transaction(async (tx) => { await tx.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.sourceResultId, id)); await tx.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id)); }); }