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, modelCadFiles, InsertModelCadFile, ModelCadFile, 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)); } // ─── 车型 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 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; 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"); const result = await db.insert(modelPointcloudFiles).values(data); const [row] = await db.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 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; 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"); } 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 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 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"); } 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)); }); }