feat: add local access control and managed point clouds
This commit is contained in:
257
server/db.ts
257
server/db.ts
@@ -1,6 +1,7 @@
|
||||
import { eq, desc, inArray, sql, and } from "drizzle-orm";
|
||||
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,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
||||
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
@@ -80,60 +80,67 @@ export async function getDb() {
|
||||
|
||||
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
||||
if (!user.openId) throw new Error("User openId is required for upsert");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[Database] Cannot upsert user: database not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values: InsertUser = { openId: user.openId };
|
||||
const updateSet: Record<string, unknown> = {};
|
||||
|
||||
const textFields = ["name", "email", "loginMethod"] as const;
|
||||
type TextField = (typeof textFields)[number];
|
||||
|
||||
const assignNullable = (field: TextField) => {
|
||||
const value = user[field];
|
||||
if (value === undefined) return;
|
||||
const normalized = value ?? null;
|
||||
values[field] = normalized;
|
||||
updateSet[field] = normalized;
|
||||
};
|
||||
textFields.forEach(assignNullable);
|
||||
|
||||
if (user.lastSignedIn !== undefined) {
|
||||
values.lastSignedIn = user.lastSignedIn;
|
||||
updateSet.lastSignedIn = user.lastSignedIn;
|
||||
}
|
||||
if (user.role !== undefined) {
|
||||
values.role = user.role;
|
||||
updateSet.role = user.role;
|
||||
} else if (user.openId === ENV.ownerOpenId) {
|
||||
values.role = 'admin';
|
||||
updateSet.role = 'admin';
|
||||
}
|
||||
|
||||
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
||||
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
||||
|
||||
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
||||
} catch (error) {
|
||||
console.error("[Database] Failed to upsert user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByOpenId(openId: string) {
|
||||
export async function getUserById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
||||
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> {
|
||||
@@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile)
|
||||
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 [];
|
||||
return db.select().from(modelPointcloudFiles)
|
||||
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> {
|
||||
@@ -255,6 +290,36 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
|
||||
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;
|
||||
@@ -448,6 +513,7 @@ export async function createRegisteredPointcloudFile(
|
||||
): 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,
|
||||
@@ -456,15 +522,81 @@ export async function createRegisteredPointcloudFile(
|
||||
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 [];
|
||||
return db.select().from(taskPointcloudFiles)
|
||||
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> {
|
||||
@@ -507,6 +639,12 @@ export async function linkTaskPointcloudFilesFromModel(
|
||||
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);
|
||||
@@ -580,3 +718,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user