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,7 +1,7 @@
import { COOKIE_NAME } from "@shared/const";
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { adminProcedure, publicProcedure, protectedProcedure, router, sessionProcedure, superAdminProcedure } from "./_core/trpc";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as fs from "fs";
@@ -15,10 +15,37 @@ import { stepParseCacheDir } from "./stepParse.js";
import { listPlugins, getPlugin } from "./plugins/registry.js";
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.js";
import { deleteAnalysisResult } from "./analysisResultDeletion.js";
import {
clearLoginFailures,
createSessionToken,
hashPassword,
isLoginRateLimited,
publicUser,
recordLoginFailure,
validatePassword,
verifyPassword,
} from "./_core/auth";
const DATA_DIR = process.env.DATA_DIR || "/data";
const TASKS_DIR = path.join(DATA_DIR, "tasks");
function pointcloudDisplayName(baseNameInput: string, filePath: string): string {
const baseName = baseNameInput.trim();
if (!baseName) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能为空" });
}
if (/[\\/\u0000-\u001f\u007f]/.test(baseName)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能包含斜杠、反斜杠或控制字符" });
}
const name = `${baseName}${path.extname(filePath)}`;
if (Array.from(name).length > 255) {
throw new TRPCError({ code: "BAD_REQUEST", message: "点云名称不能超过 255 个字符(含扩展名)" });
}
return name;
}
const DEMO_MEASUREMENT_PLUGIN_IDS = [
"train-measurement",
"coupler-panel",
@@ -306,12 +333,164 @@ export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
login: publicProcedure
.input(z.object({ username: z.string().min(1).max(64), password: z.string().min(1).max(256) }))
.mutation(async ({ input, ctx }) => {
const username = input.username.trim();
const ip = ctx.req.ip || ctx.req.socket.remoteAddress || "unknown";
if (isLoginRateLimited(ip, username)) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "登录尝试过于频繁,请稍后再试" });
}
const user = await db.getUserByUsername(username);
const valid = Boolean(user?.isActive === 1 && await verifyPassword(input.password, user?.passwordHash ?? null));
if (!user || !valid) {
recordLoginFailure(ip, username);
throw new TRPCError({ code: "UNAUTHORIZED", message: "用户名或密码错误" });
}
clearLoginFailures(ip, username);
await db.updateUser(user.id, { lastLoginAt: new Date() });
const token = await createSessionToken(user);
ctx.res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(ctx.req),
maxAge: SESSION_TTL_MS,
});
return publicUser({ ...user, lastLoginAt: new Date() });
}),
me: sessionProcedure.query(opts => publicUser(opts.ctx.user)),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return { success: true } as const;
}),
changePassword: sessionProcedure
.input(z.object({ currentPassword: z.string().min(1).max(256), newPassword: z.string().min(8).max(256) }))
.mutation(async ({ input, ctx }) => {
if (!await verifyPassword(input.currentPassword, ctx.user.passwordHash)) {
throw new TRPCError({ code: "BAD_REQUEST", message: "当前密码错误" });
}
if (input.currentPassword === input.newPassword) {
throw new TRPCError({ code: "BAD_REQUEST", message: "新密码不能与当前密码相同" });
}
validatePassword(input.newPassword);
const passwordHash = await hashPassword(input.newPassword);
const nextVersion = ctx.user.sessionVersion + 1;
await db.updateUser(ctx.user.id, {
passwordHash,
mustChangePassword: 0,
sessionVersion: nextVersion,
});
const token = await createSessionToken({ id: ctx.user.id, sessionVersion: nextVersion });
ctx.res.cookie(COOKIE_NAME, token, {
...getSessionCookieOptions(ctx.req),
maxAge: SESSION_TTL_MS,
});
return { success: true } as const;
}),
}),
users: router({
list: superAdminProcedure
.input(z.object({ search: z.string().max(100).optional() }).optional())
.query(async ({ input }) => (await db.listUsers(input?.search)).map(publicUser)),
create: superAdminProcedure
.input(z.object({
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/, "用户名只能包含字母、数字、点、下划线和横线"),
temporaryPassword: z.string().min(8).max(256),
name: z.string().trim().min(1).max(100),
email: z.string().email().max(320).optional().or(z.literal("")),
role: z.enum(["user", "admin", "super_admin"]),
}))
.mutation(async ({ input }) => {
try {
const row = await db.createUser({
username: input.username,
passwordHash: await hashPassword(input.temporaryPassword),
name: input.name,
email: input.email || null,
role: input.role,
isActive: 1,
mustChangePassword: 1,
sessionVersion: 0,
});
if (!row) throw new Error("创建用户失败");
return publicUser(row);
} catch (error) {
if (isDuplicateKeyError(error)) {
throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
}
throw error;
}
}),
update: superAdminProcedure
.input(z.object({
id: z.number().int().positive(),
username: z.string().trim().min(3).max(64).regex(/^[A-Za-z0-9._-]+$/).optional(),
name: z.string().trim().min(1).max(100).optional(),
email: z.string().email().max(320).optional().or(z.literal("")),
role: z.enum(["user", "admin", "super_admin"]).optional(),
}))
.mutation(async ({ input, ctx }) => {
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.id === ctx.user.id && input.role && input.role !== target.role) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能修改自己的角色" });
}
if (target.role === "super_admin" && target.isActive === 1 && input.role && input.role !== "super_admin" && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能移除最后一个有效超级管理员" });
}
const { id, email, ...rest } = input;
try {
const row = await db.updateUser(id, { ...rest, ...(email !== undefined ? { email: email || null } : {}) });
if (!row) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
return publicUser(row);
} catch (error) {
if (isDuplicateKeyError(error)) throw new TRPCError({ code: "CONFLICT", message: "用户名已存在" });
throw error;
}
}),
setActive: superAdminProcedure
.input(z.object({ id: z.number().int().positive(), isActive: z.boolean() }))
.mutation(async ({ input, ctx }) => {
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能启用或禁用自己" });
if (input.isActive && (!target.passwordHash || target.username === `legacy_user_${target.id}`)) {
throw new TRPCError({ code: "FORBIDDEN", message: "遗留账号必须先修改用户名并重置临时密码" });
}
if (!input.isActive && target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能禁用最后一个有效超级管理员" });
}
const row = await db.updateUser(target.id, {
isActive: input.isActive ? 1 : 0,
sessionVersion: target.sessionVersion + 1,
});
return publicUser(row!);
}),
resetPassword: superAdminProcedure
.input(z.object({ id: z.number().int().positive(), temporaryPassword: z.string().min(8).max(256) }))
.mutation(async ({ input, ctx }) => {
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "请通过修改密码功能修改自己的密码" });
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
await db.updateUser(target.id, {
passwordHash: await hashPassword(input.temporaryPassword),
mustChangePassword: 1,
sessionVersion: target.sessionVersion + 1,
});
return { success: true } as const;
}),
delete: superAdminProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
if (input.id === ctx.user.id) throw new TRPCError({ code: "FORBIDDEN", message: "不能删除自己" });
const target = await db.getUserById(input.id);
if (!target) throw new TRPCError({ code: "NOT_FOUND", message: "用户不存在" });
if (target.role === "super_admin" && target.isActive === 1 && await db.countActiveSuperAdmins() <= 1) {
throw new TRPCError({ code: "FORBIDDEN", message: "不能删除最后一个有效超级管理员" });
}
await db.deleteUser(target.id);
return { success: true } as const;
}),
}),
// ─── 车系 ──────────────────────────────────────────────────────────────────
@@ -444,6 +623,23 @@ export const appRouter = router({
return db.listModelPointcloudFiles(input.modelId);
}),
rename: adminProcedure
.input(z.object({ id: z.number(), baseName: z.string() }))
.mutation(async ({ input }) => {
const row = await db.getModelPointcloudFileById(input.id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "车型库点云不存在" });
}
const name = pointcloudDisplayName(input.baseName, row.filePath);
if (name === row.name) return row;
const duplicate = await db.findModelPointcloudFileByModelAndName(row.modelId, name, row.id);
if (duplicate) {
throw new TRPCError({ code: "CONFLICT", message: "当前车型下已存在同名点云" });
}
return db.updateModelPointcloudFileName(row.id, name);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
@@ -596,6 +792,23 @@ export const appRouter = router({
return db.listTaskPointcloudFiles(input.taskId);
}),
rename: adminProcedure
.input(z.object({ id: z.number(), baseName: z.string() }))
.mutation(async ({ input }) => {
const row = await db.getTaskPointcloudFileById(input.id);
if (!row) {
throw new TRPCError({ code: "NOT_FOUND", message: "任务点云不存在" });
}
const name = pointcloudDisplayName(input.baseName, row.filePath);
if (name === row.name) return row;
const duplicate = await db.findTaskPointcloudFileByTaskAndName(row.taskId, name, row.id);
if (duplicate) {
throw new TRPCError({ code: "CONFLICT", message: "当前任务下已存在同名点云" });
}
return db.updateTaskPointcloudFileName(row.id, name);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
@@ -801,11 +1014,7 @@ export const appRouter = router({
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
// 先取消正在运行或排队的 job,再删数据库记录
cancelAlgorithmJob(input.id);
cancelLegacyAnalysisJob(input.id);
await db.deleteTaskAnalysisResult(input.id);
return { success: true };
return deleteAnalysisResult(input.id);
}),
}),