734 lines
27 KiB
TypeScript
734 lines
27 KiB
TypeScript
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<typeof drizzle> | 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<InsertUser>) {
|
||
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<number>`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<VehicleSeries> {
|
||
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<VehicleSeries[]> {
|
||
const db = await getDb();
|
||
if (!db) return [];
|
||
return db.select().from(vehicleSeries).orderBy(desc(vehicleSeries.createdAt));
|
||
}
|
||
|
||
export async function updateVehicleSeries(id: number, data: Partial<InsertVehicleSeries>): Promise<void> {
|
||
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<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
await db.delete(vehicleSeries).where(eq(vehicleSeries.id, id));
|
||
}
|
||
|
||
// ─── 车型 ─────────────────────────────────────────────────────────────────────
|
||
|
||
export async function createVehicleModel(data: InsertVehicleModel): Promise<VehicleModel> {
|
||
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<VehicleModel[]> {
|
||
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<VehicleModel | undefined> {
|
||
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<InsertVehicleModel>): Promise<void> {
|
||
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<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
await db.delete(vehicleModels).where(eq(vehicleModels.id, id));
|
||
}
|
||
|
||
// ─── 车型 CAD 文件 ─────────────────────────────────────────────────────────────
|
||
|
||
export async function createModelCadFile(data: InsertModelCadFile): Promise<ModelCadFile> {
|
||
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<ModelCadFile[]> {
|
||
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<ModelCadFile | undefined> {
|
||
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<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
await db.delete(modelCadFiles).where(eq(modelCadFiles.id, id));
|
||
}
|
||
|
||
// ─── 车型点云文件 ─────────────────────────────────────────────────────────────
|
||
|
||
export async function createModelPointcloudFile(data: InsertModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||
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<number, Promise<ModelPointcloudFile>>();
|
||
|
||
async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise<ModelPointcloudFile> {
|
||
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<ModelPointcloudFile[]> {
|
||
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<ModelPointcloudFile | undefined> {
|
||
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<ModelPointcloudFile | undefined> {
|
||
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<ModelPointcloudFile> {
|
||
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<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
await db.delete(modelPointcloudFiles).where(eq(modelPointcloudFiles.id, id));
|
||
}
|
||
|
||
// ─── 任务 CAD 文件 ─────────────────────────────────────────────────────────────
|
||
|
||
export async function createTaskCadFile(data: InsertTaskCadFile): Promise<TaskCadFile> {
|
||
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<TaskCadFile[]> {
|
||
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<TaskCadFile | undefined> {
|
||
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<void> {
|
||
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<TaskCadFile[]> {
|
||
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<InspectionTask> {
|
||
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<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 [];
|
||
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));
|
||
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> {
|
||
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<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));
|
||
}
|
||
|
||
// ─── 任务点云文件 ──────────────────────────────────────────────────────────────
|
||
|
||
export async function createTaskPointcloudFile(data: InsertTaskPointcloudFile): Promise<TaskPointcloudFile> {
|
||
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<TaskPointcloudFile> {
|
||
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<number, Promise<TaskPointcloudFile>>();
|
||
|
||
async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise<TaskPointcloudFile> {
|
||
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<TaskPointcloudFile[]> {
|
||
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<TaskPointcloudFile | undefined> {
|
||
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<TaskPointcloudFile | undefined> {
|
||
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<TaskPointcloudFile> {
|
||
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<void> {
|
||
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<TaskPointcloudFile[]> {
|
||
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<TaskAnalysisResult> {
|
||
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<TaskAnalysisResult[]> {
|
||
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<TaskAnalysisResult | undefined> {
|
||
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<InsertTaskAnalysisResult>): Promise<void> {
|
||
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<string, unknown>,
|
||
): Promise<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
const existing = await getTaskAnalysisResultById(id);
|
||
if (!existing) return;
|
||
const prev = (existing.resultData as Record<string, unknown> | null) ?? {};
|
||
const merged: Record<string, unknown> = { ...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<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
||
}
|
||
|
||
/**
|
||
* 删除分析结果及其派生的已配准点云记录。
|
||
* 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。
|
||
*/
|
||
export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise<void> {
|
||
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));
|
||
});
|
||
}
|