feat: add local access control and managed point clouds

This commit is contained in:
2026-07-22 11:05:48 +08:00
parent 683e4d6c07
commit 8555aa2f2e
46 changed files with 1982 additions and 884 deletions

View File

@@ -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));
});
}