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

153
server/_core/auth.ts Normal file
View File

@@ -0,0 +1,153 @@
import { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
import { parse as parseCookieHeader } from "cookie";
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "crypto";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import { promisify } from "util";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
const scrypt = promisify(scryptCallback);
const PASSWORD_MIN_LENGTH = 8;
function sessionSecret() {
if (!ENV.cookieSecret || ENV.cookieSecret.length < 32) {
throw new Error("JWT_SECRET 必须配置且至少 32 个字符");
}
return new TextEncoder().encode(ENV.cookieSecret);
}
export function validatePassword(password: string) {
if (password.length < PASSWORD_MIN_LENGTH) {
throw new Error(`密码至少需要 ${PASSWORD_MIN_LENGTH} 位`);
}
}
export async function hashPassword(password: string) {
validatePassword(password);
const salt = randomBytes(16);
const derived = await scrypt(password, salt, 64) as Buffer;
return `scrypt$${salt.toString("base64url")}$${derived.toString("base64url")}`;
}
export async function verifyPassword(password: string, encoded: string | null) {
if (!encoded) return false;
const [scheme, saltValue, hashValue] = encoded.split("$");
if (scheme !== "scrypt" || !saltValue || !hashValue) return false;
try {
const expected = Buffer.from(hashValue, "base64url");
const actual = await scrypt(password, Buffer.from(saltValue, "base64url"), expected.length) as Buffer;
return actual.length === expected.length && timingSafeEqual(actual, expected);
} catch {
return false;
}
}
export async function createSessionToken(user: Pick<User, "id" | "sessionVersion">) {
return new SignJWT({ userId: user.id, sessionVersion: user.sessionVersion })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(Math.floor((Date.now() + SESSION_TTL_MS) / 1000))
.sign(sessionSecret());
}
function readSessionCookie(req: Request) {
if (!req.headers.cookie) return undefined;
return parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
}
export async function authenticateRequest(req: Request): Promise<User | null> {
const token = readSessionCookie(req);
if (!token) return null;
try {
const { payload } = await jwtVerify(token, sessionSecret(), { algorithms: ["HS256"] });
const userId = Number(payload.userId);
const sessionVersion = Number(payload.sessionVersion);
if (!Number.isInteger(userId) || !Number.isInteger(sessionVersion)) return null;
const user = await db.getUserById(userId);
if (!user || user.isActive !== 1 || user.sessionVersion !== sessionVersion) return null;
return user;
} catch {
return null;
}
}
type Attempt = { failures: number[] };
const attempts = new Map<string, Attempt>();
const RATE_WINDOW_MS = 15 * 60 * 1000;
const RATE_MAX_FAILURES = 5;
function normalizedUsername(username: string) {
return username.trim().toLowerCase();
}
function attemptKeys(ip: string, username: string) {
return [`ip:${ip}`, `username:${normalizedUsername(username)}`];
}
function recentFailures(key: string, now = Date.now()) {
const record = attempts.get(key);
if (!record) return [];
record.failures = record.failures.filter(time => now - time < RATE_WINDOW_MS);
if (record.failures.length === 0) attempts.delete(key);
return record.failures;
}
export function isLoginRateLimited(ip: string, username: string) {
return attemptKeys(ip, username).some(key => recentFailures(key).length >= RATE_MAX_FAILURES);
}
export function recordLoginFailure(ip: string, username: string) {
const now = Date.now();
for (const key of attemptKeys(ip, username)) {
const failures = recentFailures(key, now);
failures.push(now);
attempts.set(key, { failures });
}
}
export function clearLoginFailures(ip: string, username: string) {
for (const key of attemptKeys(ip, username)) attempts.delete(key);
}
export function resetLoginRateLimitForTests() {
attempts.clear();
}
export async function ensureBootstrapAdmin() {
if (await db.countActiveSuperAdmins() > 0) return;
const username = ENV.bootstrapAdminUsername.trim();
const password = ENV.bootstrapAdminPassword;
if (!username || !password) {
throw new Error("系统不存在有效超级管理员,请配置 BOOTSTRAP_ADMIN_USERNAME 和 BOOTSTRAP_ADMIN_PASSWORD");
}
validatePassword(password);
const existing = await db.getUserByUsername(username);
const passwordHash = await hashPassword(password);
if (existing) {
await db.updateUser(existing.id, {
passwordHash,
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: existing.sessionVersion + 1,
});
} else {
await db.createUser({
username,
passwordHash,
name: "系统管理员",
role: "super_admin",
isActive: 1,
mustChangePassword: 1,
sessionVersion: 0,
});
}
console.log(`[Auth] 已创建引导超级管理员账号:${username}(首次登录必须修改密码)`);
}
export function publicUser(user: User) {
const { passwordHash: _passwordHash, ...safe } = user;
return safe;
}

View File

@@ -1,6 +1,6 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import type { User } from "../../drizzle/schema";
import { sdk } from "./sdk";
import { authenticateRequest } from "./auth";
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
@@ -14,7 +14,7 @@ export async function createContext(
let user: User | null = null;
try {
user = await sdk.authenticateRequest(opts.req);
user = await authenticateRequest(opts.req);
} catch (error) {
// Authentication is optional for public procedures.
user = null;

View File

@@ -1,13 +1,5 @@
import type { CookieOptions, Request } from "express";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
function isIpAddress(host: string) {
// Basic IPv4 check and IPv6 presence detection.
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true;
return host.includes(":");
}
function isSecureRequest(req: Request) {
if (req.protocol === "https") return true;
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
return {
httpOnly: true,
path: "/",
sameSite: "none",
sameSite: "lax",
secure: isSecureRequest(req),
};
}

View File

@@ -1,13 +1,8 @@
export const ENV = {
appId: process.env.VITE_APP_ID ?? "",
cookieSecret: process.env.JWT_SECRET ?? "",
databaseUrl: process.env.DATABASE_URL ?? "",
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
authMode: process.env.AUTH_MODE ?? "oauth",
localDevOpenId: process.env.LOCAL_DEV_OPEN_ID ?? "local-dev-user",
localDevName: process.env.LOCAL_DEV_NAME ?? "Local Dev User",
localDevEmail: process.env.LOCAL_DEV_EMAIL ?? "local-dev@example.com",
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
isProduction: process.env.NODE_ENV === "production",
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",

View File

@@ -7,11 +7,11 @@ import path from "path";
import { fileURLToPath } from "url";
import mysql from "mysql2/promise";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { fileRouter } from "../fileRoutes";
import { ensureBootstrapAdmin } from "./auth";
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
}
async function startServer() {
try {
await runMigrations();
} catch (e) {
console.error("[Migration] Unexpected error, server will still start:", e);
}
await runMigrations();
await ensureBootstrapAdmin();
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
@@ -128,8 +125,6 @@ async function startServer() {
server.timeout = 0; // 禁用 Node http.Server 全局超时
server.keepAliveTimeout = 0; // 长连接不超时
server.headersTimeout = 0; // 读请求头不超时
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// 禁止代理(NPS 等)缓存 API 响应
app.use('/api', (_req, res, next) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');

View File

@@ -1,53 +0,0 @@
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import type { Express, Request, Response } from "express";
import * as db from "../db";
import { getSessionCookieOptions } from "./cookies";
import { sdk } from "./sdk";
function getQueryParam(req: Request, key: string): string | undefined {
const value = req.query[key];
return typeof value === "string" ? value : undefined;
}
export function registerOAuthRoutes(app: Express) {
app.get("/api/oauth/callback", async (req: Request, res: Response) => {
const code = getQueryParam(req, "code");
const state = getQueryParam(req, "state");
if (!code || !state) {
res.status(400).json({ error: "code and state are required" });
return;
}
try {
const tokenResponse = await sdk.exchangeCodeForToken(code, state);
const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
if (!userInfo.openId) {
res.status(400).json({ error: "openId missing from user info" });
return;
}
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: new Date(),
});
const sessionToken = await sdk.createSessionToken(userInfo.openId, {
name: userInfo.name || "",
expiresInMs: ONE_YEAR_MS,
});
const cookieOptions = getSessionCookieOptions(req);
res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS });
res.redirect(302, "/");
} catch (error) {
console.error("[OAuth] Callback failed", error);
res.status(500).json({ error: "OAuth callback failed" });
}
});
}

View File

@@ -1,346 +0,0 @@
import { AXIOS_TIMEOUT_MS, COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
import { ForbiddenError } from "@shared/_core/errors";
import axios, { type AxiosInstance } from "axios";
import { parse as parseCookieHeader } from "cookie";
import type { Request } from "express";
import { SignJWT, jwtVerify } from "jose";
import type { User } from "../../drizzle/schema";
import * as db from "../db";
import { ENV } from "./env";
import type {
ExchangeTokenRequest,
ExchangeTokenResponse,
GetUserInfoResponse,
GetUserInfoWithJwtRequest,
GetUserInfoWithJwtResponse,
} from "./types/manusTypes";
// Utility function
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
export type SessionPayload = {
openId: string;
appId: string;
name: string;
};
const EXCHANGE_TOKEN_PATH = `/webdev.v1.WebDevAuthPublicService/ExchangeToken`;
const GET_USER_INFO_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfo`;
const GET_USER_INFO_WITH_JWT_PATH = `/webdev.v1.WebDevAuthPublicService/GetUserInfoWithJwt`;
class OAuthService {
constructor(private client: ReturnType<typeof axios.create>) {
const isLocalAuth = ENV.authMode === "local";
console.log("[OAuth] Initialized with baseURL:", ENV.oAuthServerUrl);
if (!ENV.oAuthServerUrl && !isLocalAuth) {
console.error(
"[OAuth] ERROR: OAUTH_SERVER_URL is not configured! Set OAUTH_SERVER_URL environment variable."
);
} else if (!ENV.oAuthServerUrl && isLocalAuth) {
console.log("[OAuth] AUTH_MODE=local, OAuth server is not required.");
}
}
private decodeState(state: string): string {
const redirectUri = atob(state);
return redirectUri;
}
async getTokenByCode(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
const payload: ExchangeTokenRequest = {
clientId: ENV.appId,
grantType: "authorization_code",
code,
redirectUri: this.decodeState(state),
};
const { data } = await this.client.post<ExchangeTokenResponse>(
EXCHANGE_TOKEN_PATH,
payload
);
return data;
}
async getUserInfoByToken(
token: ExchangeTokenResponse
): Promise<GetUserInfoResponse> {
const { data } = await this.client.post<GetUserInfoResponse>(
GET_USER_INFO_PATH,
{
accessToken: token.accessToken,
}
);
return data;
}
}
const createOAuthHttpClient = (): AxiosInstance =>
axios.create({
baseURL: ENV.oAuthServerUrl,
timeout: AXIOS_TIMEOUT_MS,
});
class SDKServer {
private readonly client: AxiosInstance;
private readonly oauthService: OAuthService;
constructor(client: AxiosInstance = createOAuthHttpClient()) {
this.client = client;
this.oauthService = new OAuthService(this.client);
}
private isLocalAuthMode() {
return ENV.authMode === "local";
}
private deriveLoginMethod(
platforms: unknown,
fallback: string | null | undefined
): string | null {
if (fallback && fallback.length > 0) return fallback;
if (!Array.isArray(platforms) || platforms.length === 0) return null;
const set = new Set<string>(
platforms.filter((p): p is string => typeof p === "string")
);
if (set.has("REGISTERED_PLATFORM_EMAIL")) return "email";
if (set.has("REGISTERED_PLATFORM_GOOGLE")) return "google";
if (set.has("REGISTERED_PLATFORM_APPLE")) return "apple";
if (
set.has("REGISTERED_PLATFORM_MICROSOFT") ||
set.has("REGISTERED_PLATFORM_AZURE")
)
return "microsoft";
if (set.has("REGISTERED_PLATFORM_GITHUB")) return "github";
const first = Array.from(set)[0];
return first ? first.toLowerCase() : null;
}
/**
* Exchange OAuth authorization code for access token
* @example
* const tokenResponse = await sdk.exchangeCodeForToken(code, state);
*/
async exchangeCodeForToken(
code: string,
state: string
): Promise<ExchangeTokenResponse> {
return this.oauthService.getTokenByCode(code, state);
}
/**
* Get user information using access token
* @example
* const userInfo = await sdk.getUserInfo(tokenResponse.accessToken);
*/
async getUserInfo(accessToken: string): Promise<GetUserInfoResponse> {
const data = await this.oauthService.getUserInfoByToken({
accessToken,
} as ExchangeTokenResponse);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoResponse;
}
private parseCookies(cookieHeader: string | undefined) {
if (!cookieHeader) {
return new Map<string, string>();
}
const parsed = parseCookieHeader(cookieHeader);
return new Map(Object.entries(parsed));
}
private getSessionSecret() {
const secret = ENV.cookieSecret;
return new TextEncoder().encode(secret);
}
/**
* Create a session token for a Manus user openId
* @example
* const sessionToken = await sdk.createSessionToken(userInfo.openId);
*/
async createSessionToken(
openId: string,
options: { expiresInMs?: number; name?: string } = {}
): Promise<string> {
return this.signSession(
{
openId,
appId: ENV.appId,
name: options.name || "",
},
options
);
}
async signSession(
payload: SessionPayload,
options: { expiresInMs?: number } = {}
): Promise<string> {
const issuedAt = Date.now();
const expiresInMs = options.expiresInMs ?? ONE_YEAR_MS;
const expirationSeconds = Math.floor((issuedAt + expiresInMs) / 1000);
const secretKey = this.getSessionSecret();
return new SignJWT({
openId: payload.openId,
appId: payload.appId,
name: payload.name,
})
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setExpirationTime(expirationSeconds)
.sign(secretKey);
}
async verifySession(
cookieValue: string | undefined | null
): Promise<{ openId: string; appId: string; name: string } | null> {
if (!cookieValue) {
console.warn("[Auth] Missing session cookie");
return null;
}
try {
const secretKey = this.getSessionSecret();
const { payload } = await jwtVerify(cookieValue, secretKey, {
algorithms: ["HS256"],
});
const { openId, appId, name } = payload as Record<string, unknown>;
if (
!isNonEmptyString(openId) ||
!isNonEmptyString(appId) ||
!isNonEmptyString(name)
) {
console.warn("[Auth] Session payload missing required fields");
return null;
}
return {
openId,
appId,
name,
};
} catch (error) {
console.warn("[Auth] Session verification failed", String(error));
return null;
}
}
async getUserInfoWithJwt(
jwtToken: string
): Promise<GetUserInfoWithJwtResponse> {
const payload: GetUserInfoWithJwtRequest = {
jwtToken,
projectId: ENV.appId,
};
const { data } = await this.client.post<GetUserInfoWithJwtResponse>(
GET_USER_INFO_WITH_JWT_PATH,
payload
);
const loginMethod = this.deriveLoginMethod(
(data as any)?.platforms,
(data as any)?.platform ?? data.platform ?? null
);
return {
...(data as any),
platform: loginMethod,
loginMethod,
} as GetUserInfoWithJwtResponse;
}
async authenticateRequest(req: Request): Promise<User> {
if (this.isLocalAuthMode()) {
const signedInAt = new Date();
const localOpenId = ENV.localDevOpenId;
try {
await db.upsertUser({
openId: localOpenId,
name: ENV.localDevName,
email: ENV.localDevEmail,
loginMethod: "local",
lastSignedIn: signedInAt,
});
const localUser = await db.getUserByOpenId(localOpenId);
if (localUser) return localUser;
} catch {
// DB not ready yet (e.g. MySQL still starting up); fall through to
// an in-memory guest user so the UI keeps working.
console.warn("[Auth] DB unavailable in local mode, using fallback user");
}
// Fallback: return a transient in-memory user so requests don't fail
// while MySQL is warming up.
return {
id: 0,
openId: localOpenId,
name: ENV.localDevName ?? "Dev User",
email: ENV.localDevEmail ?? null,
loginMethod: "local",
role: "admin" as const,
createdAt: signedInAt,
updatedAt: signedInAt,
lastSignedIn: signedInAt,
};
}
// Regular authentication flow
const cookies = this.parseCookies(req.headers.cookie);
const sessionCookie = cookies.get(COOKIE_NAME);
const session = await this.verifySession(sessionCookie);
if (!session) {
throw ForbiddenError("Invalid session cookie");
}
const sessionUserId = session.openId;
const signedInAt = new Date();
let user = await db.getUserByOpenId(sessionUserId);
// If user not in DB, sync from OAuth server automatically
if (!user) {
try {
const userInfo = await this.getUserInfoWithJwt(sessionCookie ?? "");
await db.upsertUser({
openId: userInfo.openId,
name: userInfo.name || null,
email: userInfo.email ?? null,
loginMethod: userInfo.loginMethod ?? userInfo.platform ?? null,
lastSignedIn: signedInAt,
});
user = await db.getUserByOpenId(userInfo.openId);
} catch (error) {
console.error("[Auth] Failed to sync user from OAuth:", error);
throw ForbiddenError("Failed to sync user info");
}
}
if (!user) {
throw ForbiddenError("User not found");
}
await db.upsertUser({
openId: user.openId,
lastSignedIn: signedInAt,
});
return user;
}
}
export const sdk = new SDKServer();

View File

@@ -1,4 +1,4 @@
import { NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { MUST_CHANGE_PASSWORD_ERR_MSG, NOT_ADMIN_ERR_MSG, UNAUTHED_ERR_MSG } from '@shared/const';
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { TrpcContext } from "./context";
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
export const router = t.router;
export const publicProcedure = t.procedure;
const requireUser = t.middleware(async opts => {
const requireSession = t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user) {
@@ -25,13 +25,34 @@ const requireUser = t.middleware(async opts => {
});
});
export const protectedProcedure = t.procedure.use(requireUser);
export const sessionProcedure = t.procedure.use(requireSession);
const requireReadableUser = t.middleware(async opts => {
if (!opts.ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.type === "mutation" && opts.ctx.user.role === "user") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
});
export const protectedProcedure = t.procedure.use(requireReadableUser);
export const adminProcedure = t.procedure.use(
t.middleware(async opts => {
const { ctx, next } = opts;
if (!ctx.user || ctx.user.role !== 'admin') {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
}
if (ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (ctx.user.role !== 'admin' && ctx.user.role !== 'super_admin') {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
@@ -43,3 +64,16 @@ export const adminProcedure = t.procedure.use(
});
}),
);
export const superAdminProcedure = t.procedure.use(
t.middleware(async opts => {
if (!opts.ctx.user) throw new TRPCError({ code: "UNAUTHORIZED", message: UNAUTHED_ERR_MSG });
if (opts.ctx.user.mustChangePassword === 1) {
throw new TRPCError({ code: "FORBIDDEN", message: MUST_CHANGE_PASSWORD_ERR_MSG });
}
if (opts.ctx.user.role !== "super_admin") {
throw new TRPCError({ code: "FORBIDDEN", message: NOT_ADMIN_ERR_MSG });
}
return opts.next({ ctx: { ...opts.ctx, user: opts.ctx.user } });
}),
);

View File

@@ -1,69 +0,0 @@
// WebDev Auth TypeScript types
// Auto-generated from protobuf definitions
// Generated on: 2025-09-24T05:57:57.338Z
export interface AuthorizeRequest {
redirectUri: string;
projectId: string;
state: string;
responseType: string;
scope: string;
}
export interface AuthorizeResponse {
redirectUrl: string;
}
export interface ExchangeTokenRequest {
grantType: string;
code: string;
refreshToken?: string;
clientId: string;
clientSecret?: string;
redirectUri: string;
}
export interface ExchangeTokenResponse {
accessToken: string;
tokenType: string;
expiresIn: number;
refreshToken?: string;
scope: string;
idToken: string;
}
export interface GetUserInfoRequest {
accessToken: string;
}
export interface GetUserInfoResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}
export interface CanAccessRequest {
openId: string;
projectId: string;
}
export interface CanAccessResponse {
canAccess: boolean;
}
export interface GetUserInfoWithJwtRequest {
jwtToken: string;
projectId: string;
}
export interface GetUserInfoWithJwtResponse {
openId: string;
projectId: string;
name: string;
email?: string | null;
platform?: string | null;
loginMethod?: string | null;
}

View File

@@ -0,0 +1,57 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { cleanupAnalysisArtifacts, getAnalysisArtifactPaths } from "./analysisArtifacts";
const tempDirs: string[] = [];
async function makeDataDir() {
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "analysis-artifacts-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.promises.rm(dir, { recursive: true, force: true })));
});
describe("cleanupAnalysisArtifacts", () => {
it("deletes the plugin directory and all legacy files for only the requested result", async () => {
const dataDir = await makeDataDir();
const analysisDir = path.join(dataDir, "tasks", "7", "analysis");
await fs.promises.mkdir(path.join(analysisDir, "57"), { recursive: true });
await fs.promises.mkdir(path.join(analysisDir, "58"), { recursive: true });
const files = [
"heatmap_57.bin",
"deviation_57.bin",
"assembly_vertex_57_car-body.bin",
"assembly_vertex_57_bogie.bin",
"heatmap_58.bin",
"assembly_vertex_570_other.bin",
];
await Promise.all(files.map((name) => fs.promises.writeFile(path.join(analysisDir, name), name)));
await cleanupAnalysisArtifacts(7, 57, dataDir);
await expect(fs.promises.stat(path.join(analysisDir, "57"))).rejects.toMatchObject({ code: "ENOENT" });
for (const name of files.slice(0, 4)) {
await expect(fs.promises.stat(path.join(analysisDir, name))).rejects.toMatchObject({ code: "ENOENT" });
}
await expect(fs.promises.stat(path.join(analysisDir, "58"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "heatmap_58.bin"))).resolves.toBeDefined();
await expect(fs.promises.stat(path.join(analysisDir, "assembly_vertex_570_other.bin"))).resolves.toBeDefined();
});
it("rejects invalid IDs before constructing a filesystem target", () => {
expect(() => getAnalysisArtifactPaths(7, Number.NaN, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(7, -1, "/data")).toThrow("resultId");
expect(() => getAnalysisArtifactPaths(1.5, 2, "/data")).toThrow("taskId");
});
it("is idempotent when artifacts are already absent", async () => {
const dataDir = await makeDataDir();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
await expect(cleanupAnalysisArtifacts(7, 57, dataDir)).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,68 @@
import * as fs from "node:fs";
import * as path from "node:path";
export type AnalysisArtifactPaths = {
analysisDir: string;
resultDir: string;
legacyFiles: string[];
};
function assertNumericId(name: string, value: number): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive safe integer`);
}
}
export function getAnalysisArtifactPaths(
taskId: number,
resultId: number,
dataDir = process.env.DATA_DIR || "/data",
): AnalysisArtifactPaths {
assertNumericId("taskId", taskId);
assertNumericId("resultId", resultId);
const tasksDir = path.resolve(dataDir, "tasks");
const taskDir = path.resolve(tasksDir, String(taskId));
const analysisDir = path.resolve(taskDir, "analysis");
const resultDir = path.resolve(analysisDir, String(resultId));
if (path.dirname(taskDir) !== tasksDir || path.dirname(resultDir) !== analysisDir) {
throw new Error("analysis artifact path escaped its fixed root");
}
return {
analysisDir,
resultDir,
legacyFiles: [
path.join(analysisDir, `heatmap_${resultId}.bin`),
path.join(analysisDir, `deviation_${resultId}.bin`),
],
};
}
export async function cleanupAnalysisArtifacts(
taskId: number,
resultId: number,
dataDir = process.env.DATA_DIR || "/data",
): Promise<void> {
const paths = getAnalysisArtifactPaths(taskId, resultId, dataDir);
await fs.promises.rm(paths.resultDir, { recursive: true, force: true });
let entries: string[] = [];
try {
entries = await fs.promises.readdir(paths.analysisDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const assemblyPrefix = `assembly_vertex_${resultId}_`;
const assemblyFiles = entries
.filter((name) => name.startsWith(assemblyPrefix) && name.endsWith(".bin"))
.map((name) => path.join(paths.analysisDir, name));
await Promise.all(
[...paths.legacyFiles, ...assemblyFiles].map((file) =>
fs.promises.rm(file, { force: true }),
),
);
}

View File

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as db from "./db";
import { cancelAlgorithmJob } from "./algorithmJob";
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
import { deleteAnalysisResult } from "./analysisResultDeletion";
vi.mock("./db", () => ({
getTaskAnalysisResultById: vi.fn(),
deleteTaskAnalysisResultWithDerivedPointclouds: vi.fn(),
}));
vi.mock("./algorithmJob", () => ({ cancelAlgorithmJob: vi.fn() }));
vi.mock("./analysisJobControl", () => ({ cancelLegacyAnalysisJob: vi.fn() }));
vi.mock("./analysisArtifacts", () => ({ cleanupAnalysisArtifacts: vi.fn() }));
const row = { id: 57, taskId: 7 } as Awaited<ReturnType<typeof db.getTaskAnalysisResultById>>;
describe("deleteAnalysisResult", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValue(row);
vi.mocked(cleanupAnalysisArtifacts).mockResolvedValue(undefined);
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mockResolvedValue(undefined);
});
it("cancels jobs, removes files, then transactionally cascades derived pointcloud rows", async () => {
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
expect(cancelAlgorithmJob).toHaveBeenCalledWith(57);
expect(cancelLegacyAnalysisJob).toHaveBeenCalledWith(57);
expect(cleanupAnalysisArtifacts).toHaveBeenCalledWith(7, 57);
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).toHaveBeenCalledWith(57);
expect(vi.mocked(cleanupAnalysisArtifacts).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(db.deleteTaskAnalysisResultWithDerivedPointclouds).mock.invocationCallOrder[0],
);
});
it("keeps database rows when filesystem cleanup fails", async () => {
vi.mocked(cleanupAnalysisArtifacts).mockRejectedValueOnce(new Error("permission denied"));
await expect(deleteAnalysisResult(57)).rejects.toThrow("permission denied");
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
});
it("treats deletion of an already missing result as success", async () => {
vi.mocked(db.getTaskAnalysisResultById).mockResolvedValueOnce(undefined);
await expect(deleteAnalysisResult(57)).resolves.toEqual({ success: true });
expect(cleanupAnalysisArtifacts).not.toHaveBeenCalled();
expect(db.deleteTaskAnalysisResultWithDerivedPointclouds).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
import * as db from "./db";
import { cancelAlgorithmJob } from "./algorithmJob";
import { cancelLegacyAnalysisJob } from "./analysisJobControl";
import { cleanupAnalysisArtifacts } from "./analysisArtifacts";
export async function deleteAnalysisResult(id: number): Promise<{ success: true }> {
const row = await db.getTaskAnalysisResultById(id);
if (!row) return { success: true };
cancelAlgorithmJob(id);
cancelLegacyAnalysisJob(id);
// 文件删除失败时不触碰数据库,保留记录以便用户重试。
await cleanupAnalysisArtifacts(row.taskId, row.id);
await db.deleteTaskAnalysisResultWithDerivedPointclouds(id);
return { success: true };
}

View File

@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
const user: AuthenticatedUser = {
id: 1,
openId: "sample-user",
username: "sample-user",
passwordHash: null,
email: "sample@example.com",
name: "Sample User",
loginMethod: "manus",
role: "user",
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
lastLoginAt: new Date(),
};
const ctx: TrpcContext = {
@@ -54,7 +57,7 @@ describe("auth.logout", () => {
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
sameSite: "lax",
httpOnly: true,
path: "/",
});

View File

@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
hashPassword,
isLoginRateLimited,
recordLoginFailure,
resetLoginRateLimitForTests,
verifyPassword,
} from "./_core/auth";
describe("本地密码认证", () => {
beforeEach(() => resetLoginRateLimitForTests());
it("使用带随机盐的 scrypt 哈希校验密码", async () => {
const first = await hashPassword("correct-password");
const second = await hashPassword("correct-password");
expect(first).toMatch(/^scrypt\$/);
expect(first).not.toBe(second);
await expect(verifyPassword("correct-password", first)).resolves.toBe(true);
await expect(verifyPassword("wrong-password", first)).resolves.toBe(false);
});
it("拒绝少于 8 位的密码", async () => {
await expect(hashPassword("short")).rejects.toThrow("至少需要 8 位");
});
it("分别按 IP 和用户名限制连续失败", () => {
for (let i = 0; i < 5; i += 1) recordLoginFailure("10.0.0.1", "alice");
expect(isLoginRateLimited("10.0.0.1", "another-user")).toBe(true);
expect(isLoginRateLimited("another-ip", "ALICE")).toBe(true);
expect(isLoginRateLimited("another-ip", "bob")).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./db", () => ({
countActiveSuperAdmins: vi.fn(),
getUserByUsername: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
}));
import * as db from "./db";
import { ensureBootstrapAdmin } from "./_core/auth";
import { ENV } from "./_core/env";
describe("引导超级管理员", () => {
beforeEach(() => {
vi.clearAllMocks();
ENV.bootstrapAdminUsername = "bootstrap-test";
ENV.bootstrapAdminPassword = "bootstrap-password";
});
it("不存在有效超级管理员时只创建一次", async () => {
vi.mocked(db.countActiveSuperAdmins).mockResolvedValueOnce(0).mockResolvedValueOnce(1);
vi.mocked(db.getUserByUsername).mockResolvedValue(undefined);
vi.mocked(db.createUser).mockResolvedValue({} as never);
await ensureBootstrapAdmin();
await ensureBootstrapAdmin();
expect(db.createUser).toHaveBeenCalledTimes(1);
expect(db.createUser).toHaveBeenCalledWith(expect.objectContaining({ role: "super_admin", isActive: 1, mustChangePassword: 1 }));
});
it("无有效超级管理员且缺少变量时启动失败", async () => {
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(0);
ENV.bootstrapAdminUsername = "";
ENV.bootstrapAdminPassword = "";
await expect(ensureBootstrapAdmin()).rejects.toThrow("BOOTSTRAP_ADMIN_USERNAME");
});
});

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

View File

@@ -1,10 +1,12 @@
import { Router } from 'express';
import { Router, type RequestHandler } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import multer from 'multer';
import * as db from './db';
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
import { authenticateRequest } from './_core/auth.js';
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
const router = Router();
@@ -48,6 +50,33 @@ const diskUpload = multer({
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
});
const requireFileRead: RequestHandler = async (req, res, next) => {
try {
const user = await authenticateRequest(req);
if (!user) return res.status(401).json({ error: '请先登录' });
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
res.locals.user = user;
next();
} catch (error) {
next(error);
}
};
const requireFileWrite: RequestHandler = async (req, res, next) => {
try {
const user = await authenticateRequest(req);
if (!user) return res.status(401).json({ error: '请先登录' });
if (user.mustChangePassword === 1) return res.status(403).json({ error: '请先修改初始密码' });
if (user.role !== 'admin' && user.role !== 'super_admin') {
return res.status(403).json({ error: '无权执行此操作' });
}
res.locals.user = user;
next();
} catch (error) {
next(error);
}
};
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
function formatFileSize(bytes: number): string {
@@ -66,7 +95,7 @@ function safeResolve(base: string, filename: string): string | null {
// POST /api/files/upload/cad/:modelId
// multipart/form-data field: file
router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/cad/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
@@ -98,18 +127,20 @@ router.post('/upload/cad/:modelId', diskUpload.single('file'), async (req, res)
// POST /api/files/upload/model-pointcloud/:modelId
// multipart/form-data field: file
router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/model-pointcloud/:modelId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const modelId = parseInt(req.params.modelId, 10);
if (!req.file || isNaN(modelId)) {
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
}
const destPath = req.file.path;
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
const record = await db.createModelPointcloudFile({
modelId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
...metadata,
});
res.json({
@@ -126,7 +157,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
// POST /api/files/upload/task-cad/:taskId
router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/task-cad/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
@@ -157,7 +188,7 @@ router.post('/upload/task-cad/:taskId', diskUpload.single('file'), async (req, r
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
// GET /api/files/read/task-cad/:taskId/:filename
router.get('/read/task-cad/:taskId/:filename', (req, res) => {
router.get('/read/task-cad/:taskId/:filename', requireFileRead, (req, res) => {
const cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -168,18 +199,20 @@ router.get('/read/task-cad/:taskId/:filename', (req, res) => {
// ─── 任务点云文件上传 ─────────────────────────────────────────────────────────
// POST /api/files/upload/pointcloud/:taskId
router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req, res) => {
router.post('/upload/pointcloud/:taskId', requireFileWrite, diskUpload.single('file'), async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
if (!req.file || isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
}
const destPath = req.file.path;
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
const record = await db.createTaskPointcloudFile({
taskId,
name: req.file.originalname,
fileSize: req.file.size,
filePath: destPath,
...metadata,
});
res.json({
@@ -196,7 +229,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/cad/:modelId/:filename
router.get('/read/cad/:modelId/:filename', (req, res) => {
router.get('/read/cad/:modelId/:filename', requireFileRead, (req, res) => {
const cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
const filePath = safeResolve(cadDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -207,7 +240,7 @@ router.get('/read/cad/:modelId/:filename', (req, res) => {
// ─── 读取车型点云文件 ─────────────────────────────────────────────────────────
// GET /api/files/read/model-pointcloud/:modelId/:filename
router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
router.get('/read/model-pointcloud/:modelId/:filename', requireFileRead, (req, res) => {
const pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -218,7 +251,7 @@ router.get('/read/model-pointcloud/:modelId/:filename', (req, res) => {
// ─── 读取点云文件 ─────────────────────────────────────────────────────────────
// GET /api/files/read/pointcloud/:taskId/:filename
router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
router.get('/read/pointcloud/:taskId/:filename', requireFileRead, (req, res) => {
const pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
const filePath = safeResolve(pcDir, req.params.filename);
if (!filePath) return res.status(403).json({ error: 'Access denied' });
@@ -229,7 +262,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/cad/:id
router.delete('/delete/cad/:id', async (req, res) => {
router.delete('/delete/cad/:id', requireFileWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
@@ -245,7 +278,7 @@ router.delete('/delete/cad/:id', async (req, res) => {
// ─── 删除点云文件 ─────────────────────────────────────────────────────────────
// DELETE /api/files/delete/pointcloud/:id
router.delete('/delete/pointcloud/:id', async (req, res) => {
router.delete('/delete/pointcloud/:id', requireFileWrite, async (req, res) => {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
await db.deleteTaskPointcloudFile(id);
@@ -254,13 +287,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
// POST /api/files/sample/batch
// Body: { taskId: number, totalPoints?: number }
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
router.post('/sample/batch', async (req, res) => {
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
router.post('/sample/batch', requireFileRead, async (req, res) => {
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
taskId?: number;
filePaths?: string[];
totalPoints?: number;
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
fileIds?: number[];
@@ -268,7 +299,7 @@ router.post('/sample/batch', async (req, res) => {
let paths: string[] = [];
if (taskId != null) {
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
let records = await db.listTaskPointcloudFiles(taskId);
// 当前端传入 fileIds 时只采样选中的文件
if (Array.isArray(fileIds) && fileIds.length > 0) {
@@ -276,10 +307,8 @@ router.post('/sample/batch', async (req, res) => {
records = records.filter((r) => idSet.has(r.id));
}
paths = records.map((r) => r.filePath);
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
paths = rawPaths;
} else {
return res.status(400).json({ error: 'Provide taskId or filePaths' });
return res.status(400).json({ error: '请提供有效的 taskId' });
}
if (paths.length === 0) {
@@ -326,6 +355,7 @@ router.post('/sample/batch', async (req, res) => {
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
const hasError = sampledPerFile.some((pts) => pts.some((p) => Number.isFinite(p.error)));
const hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
const bytesPerPoint = hasColor ? 28 : 16;
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
@@ -335,7 +365,7 @@ router.post('/sample/batch', async (req, res) => {
binBuf.writeFloatLE(p.x, offset);
binBuf.writeFloatLE(p.y, offset + 4);
binBuf.writeFloatLE(p.z, offset + 8);
binBuf.writeFloatLE(p.error, offset + 12);
binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12);
if (hasColor) {
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
@@ -355,6 +385,7 @@ router.post('/sample/batch', async (req, res) => {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Point-Count', totalSampled);
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
res.setHeader('X-Has-Error', hasError ? '1' : '0');
res.setHeader('X-File-Stats', JSON.stringify(fileStats));
res.send(binBuf);
} catch (err) {
@@ -366,7 +397,7 @@ router.post('/sample/batch', async (req, res) => {
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
router.get('/read/analysis/heatmap/:taskId/:resultId', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
@@ -402,7 +433,7 @@ router.get('/read/analysis/heatmap/:taskId/:resultId', async (req, res) => {
// ─── 装配检测:每零件顶点热力缓存(可选)──────────────────────────────────────
// GET /api/files/read/analysis/assembly-vertex/:taskId/:resultId/:filename
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (req, res) => {
router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = req.params.filename;
@@ -432,7 +463,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
router.get('/read/analysis/:taskId/:resultId/*', requireFileRead, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
@@ -455,7 +486,7 @@ router.get('/read/analysis/:taskId/:resultId/*', async (req, res) => {
// ─── 分析结果重新同步 ────────────────────────────────────────────────────────
// POST /api/files/resync/analysis/:taskId/:resultId
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
router.post('/resync/analysis/:taskId/:resultId', requireFileWrite, async (req, res) => {
const taskId = parseInt(req.params.taskId, 10);
const resultId = parseInt(req.params.resultId, 10);
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
@@ -494,7 +525,7 @@ router.post('/resync/analysis/:taskId/:resultId', async (req, res) => {
// POST /api/files/plugins/reload
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
// 用于更新算法版本后立即生效。
router.post('/plugins/reload', (_req, res) => {
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
try {
const plugins = reloadPluginRegistry();
res.json({

View File

@@ -23,14 +23,17 @@ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user",
username: "test-user",
passwordHash: null,
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role: "user",
role: "admin",
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
lastLoginAt: new Date(),
};
return {

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
import { protectedProcedure, router, superAdminProcedure } from "./_core/trpc";
const testRouter = router({
read: protectedProcedure.query(() => "read-ok"),
write: protectedProcedure.mutation(() => "write-ok"),
manageUsers: superAdminProcedure.query(() => "users-ok"),
});
function user(role: User["role"], mustChangePassword = 0): User {
const now = new Date();
return {
id: 1,
username: `${role}-account`,
passwordHash: "unused",
name: role,
email: null,
role,
isActive: 1,
mustChangePassword,
sessionVersion: 0,
createdAt: now,
updatedAt: now,
lastLoginAt: null,
};
}
function context(currentUser: User | null): TrpcContext {
return { user: currentUser, req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
}
describe("三角色权限矩阵", () => {
it("未登录不能读取", async () => {
await expect(testRouter.createCaller(context(null)).read()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("首次改密前不能读取业务数据", async () => {
await expect(testRouter.createCaller(context(user("super_admin", 1))).read()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("普通用户可读但不可写,也不可管理用户", async () => {
const caller = testRouter.createCaller(context(user("user")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("管理员可读写但不可管理用户", async () => {
const caller = testRouter.createCaller(context(user("admin")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).resolves.toBe("write-ok");
await expect(caller.manageUsers()).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("超级管理员拥有全部权限", async () => {
const caller = testRouter.createCaller(context(user("super_admin")));
await expect(caller.read()).resolves.toBe("read-ok");
await expect(caller.write()).resolves.toBe("write-ok");
await expect(caller.manageUsers()).resolves.toBe("users-ok");
});
});

View File

@@ -0,0 +1,125 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
inspectPointcloudMetadata,
MAX_PLY_HEADER_BYTES,
parsePlyHeader,
PointcloudMetadataParseError,
} from "./pointcloudMetadata";
const temporaryDirectories: string[] = [];
afterEach(() => {
while (temporaryDirectories.length > 0) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
}
});
function header(lines: string[], newline = "\n") {
return Buffer.from([...lines, "end_header", ""].join(newline), "ascii");
}
describe("parsePlyHeader", () => {
it("解析 ASCII PLY,并以 element vertex 作为精确点数", () => {
const result = parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1000000000",
"property float x",
"property float y",
"property double z",
"element face 999",
"property list uchar int vertex_indices",
]));
expect(result).toEqual({
pointCount: 1_000_000_000,
pointDataFormat: "ascii",
bytesPerPoint: 16,
pointAttributes: [
{ name: "x", type: "float32", bytes: 4 },
{ name: "y", type: "float32", bytes: 4 },
{ name: "z", type: "float64", bytes: 8 },
],
});
});
it.each([
["binary_little_endian", "binary_little_endian"],
["binary_big_endian", "binary_big_endian"],
] as const)("解析 %s PLY", (format, expected) => {
const result = parsePlyHeader(header([
"ply",
`format ${format} 1.0`,
"element vertex 42",
"property int16 x",
"property ushort intensity",
"property uchar red",
]));
expect(result.pointDataFormat).toBe(expected);
expect(result.bytesPerPoint).toBe(5);
});
it("支持 CRLF 文件头和类型别名", () => {
const result = parsePlyHeader(header([
"ply",
"format ascii 1.0",
"comment generated",
"element vertex 3",
"property char classification",
"property uint confidence",
"property float32 temperature",
], "\r\n"));
expect(result.pointAttributes).toEqual([
{ name: "classification", type: "int8", bytes: 1 },
{ name: "confidence", type: "uint32", bytes: 4 },
{ name: "temperature", type: "float32", bytes: 4 },
]);
});
it("拒绝无效文件头", () => {
expect(() => parsePlyHeader(Buffer.from("not-ply\n"))).toThrow(PointcloudMetadataParseError);
});
it("拒绝未知的顶点属性类型", () => {
expect(() => parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1",
"property half x",
]))).toThrow("未知的顶点属性类型");
});
it("拒绝列表型顶点属性,但忽略其他 element 的列表属性", () => {
expect(() => parsePlyHeader(header([
"ply",
"format ascii 1.0",
"element vertex 1",
"property list uchar float samples",
]))).toThrow("列表型顶点属性");
});
it("非 PLY 格式直接标记为 unsupported,不读取文件", () => {
expect(inspectPointcloudMetadata("/path/does/not/exist.txt", "cloud.txt")).toEqual({
metadataStatus: "unsupported",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: null,
});
});
it("只读取 16KB 文件头,超长文件头标记为解析失败", () => {
const directory = mkdtempSync(join(tmpdir(), "pointcloud-metadata-"));
temporaryDirectories.push(directory);
const filePath = join(directory, "oversized.ply");
writeFileSync(filePath, `ply\nformat ascii 1.0\ncomment ${"x".repeat(MAX_PLY_HEADER_BYTES)}\nend_header\n`);
const result = inspectPointcloudMetadata(filePath, "oversized.ply");
expect(result.metadataStatus).toBe("error");
expect(result.metadataError).toContain("16KB");
});
});

View File

@@ -0,0 +1,153 @@
import * as fs from "node:fs";
import * as path from "node:path";
import type {
PointAttribute,
PointcloudDataFormat,
PointcloudMetadataValues,
} from "../shared/pointcloudMetadata";
export const MAX_PLY_HEADER_BYTES = 16 * 1024;
type ScalarType = { type: string; bytes: number };
const PLY_SCALAR_TYPES: Record<string, ScalarType> = {
char: { type: "int8", bytes: 1 },
int8: { type: "int8", bytes: 1 },
uchar: { type: "uint8", bytes: 1 },
uint8: { type: "uint8", bytes: 1 },
short: { type: "int16", bytes: 2 },
int16: { type: "int16", bytes: 2 },
ushort: { type: "uint16", bytes: 2 },
uint16: { type: "uint16", bytes: 2 },
int: { type: "int32", bytes: 4 },
int32: { type: "int32", bytes: 4 },
uint: { type: "uint32", bytes: 4 },
uint32: { type: "uint32", bytes: 4 },
float: { type: "float32", bytes: 4 },
float32: { type: "float32", bytes: 4 },
double: { type: "float64", bytes: 8 },
float64: { type: "float64", bytes: 8 },
};
export class PointcloudMetadataParseError extends Error {}
export type ParsedPlyMetadata = {
pointCount: number;
pointAttributes: PointAttribute[];
pointDataFormat: PointcloudDataFormat;
bytesPerPoint: number;
};
/** 解析内存中的 PLY 文件头。调用方应保证输入不超过 MAX_PLY_HEADER_BYTES。 */
export function parsePlyHeader(buffer: Buffer): ParsedPlyMetadata {
const text = buffer.toString("latin1");
const endMatch = /(?:^|\r?\n)end_header(?:\r?\n|$)/.exec(text);
if (!endMatch) {
throw new PointcloudMetadataParseError("未在 16KB 内找到 PLY 文件头结束标记");
}
const headerEnd = endMatch.index + endMatch[0].length;
const lines = text.slice(0, headerEnd).split(/\r?\n/).map((line) => line.trim());
if (lines[0] !== "ply") {
throw new PointcloudMetadataParseError("不是有效的 PLY 文件");
}
let pointDataFormat: PointcloudDataFormat | null = null;
let pointCount: number | null = null;
let currentElement: string | null = null;
const pointAttributes: PointAttribute[] = [];
for (const line of lines.slice(1)) {
if (!line || line === "end_header" || line.startsWith("comment ") || line.startsWith("obj_info ")) {
continue;
}
const tokens = line.split(/\s+/);
if (tokens[0] === "format") {
const value = tokens[1];
if (value !== "ascii" && value !== "binary_little_endian" && value !== "binary_big_endian") {
throw new PointcloudMetadataParseError("不支持的 PLY 编码");
}
pointDataFormat = value;
continue;
}
if (tokens[0] === "element") {
currentElement = tokens[1] ?? null;
if (currentElement === "vertex") {
const count = Number(tokens[2]);
if (!Number.isSafeInteger(count) || count < 0) {
throw new PointcloudMetadataParseError("PLY 顶点数无效");
}
pointCount = count;
}
continue;
}
if (tokens[0] === "property" && currentElement === "vertex") {
if (tokens[1] === "list") {
throw new PointcloudMetadataParseError("暂不支持列表型顶点属性");
}
const scalar = PLY_SCALAR_TYPES[tokens[1]];
const name = tokens[2];
if (!scalar || !name) {
throw new PointcloudMetadataParseError("PLY 包含未知的顶点属性类型");
}
pointAttributes.push({ name, type: scalar.type, bytes: scalar.bytes });
}
}
if (!pointDataFormat) {
throw new PointcloudMetadataParseError("PLY 文件头缺少编码信息");
}
if (pointCount === null) {
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点数量");
}
if (pointAttributes.length === 0) {
throw new PointcloudMetadataParseError("PLY 文件头缺少顶点属性");
}
return {
pointCount,
pointAttributes,
pointDataFormat,
bytesPerPoint: pointAttributes.reduce((sum, attribute) => sum + attribute.bytes, 0),
};
}
export function inspectPointcloudMetadata(filePath: string, displayName = filePath): PointcloudMetadataValues {
const extension = path.extname(displayName || filePath).toLowerCase();
if (extension !== ".ply") {
return {
metadataStatus: "unsupported",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: null,
};
}
try {
const fd = fs.openSync(filePath, "r");
try {
const buffer = Buffer.allocUnsafe(MAX_PLY_HEADER_BYTES);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
const parsed = parsePlyHeader(buffer.subarray(0, bytesRead));
return { metadataStatus: "ready", ...parsed, metadataError: null };
} finally {
fs.closeSync(fd);
}
} catch (error) {
return {
metadataStatus: "error",
pointCount: null,
pointAttributes: null,
pointDataFormat: null,
bytesPerPoint: null,
metadataError: error instanceof PointcloudMetadataParseError
? error.message
: "PLY 文件头解析失败",
};
}
}

View File

@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
import * as db from "./db";
import { appRouter } from "./routers";
vi.mock("./db", () => ({
getTaskPointcloudFileById: vi.fn(),
findTaskPointcloudFileByTaskAndName: vi.fn(),
updateTaskPointcloudFileName: vi.fn(),
getModelPointcloudFileById: vi.fn(),
findModelPointcloudFileByModelAndName: vi.fn(),
updateModelPointcloudFileName: vi.fn(),
}));
const now = new Date("2026-07-21T00:00:00Z");
const taskPointcloud = {
id: 11,
taskId: 101,
name: "原始点云.ply",
fileSize: 123,
filePath: "/data/tasks/101/physical-file.PLY",
source: "uploaded",
sourceResultId: null,
sourcePointcloudFileId: null,
sourceModelPointcloudId: null,
createdAt: now,
};
const modelPointcloud = {
id: 22,
modelId: 202,
name: "车型点云.txt",
fileSize: 456,
filePath: "/data/models/202/physical-file.txt",
createdAt: now,
};
function user(role: User["role"]): User {
return {
id: 1,
username: `${role}-account`,
passwordHash: "unused",
name: role,
email: null,
role,
isActive: 1,
mustChangePassword: 0,
sessionVersion: 0,
createdAt: now,
updatedAt: now,
lastLoginAt: null,
};
}
function caller(role: User["role"] = "admin") {
const context: TrpcContext = {
user: user(role),
req: {} as TrpcContext["req"],
res: {} as TrpcContext["res"],
};
return appRouter.createCaller(context);
}
describe("点云重命名路由", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValue(taskPointcloud);
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValue(undefined);
vi.mocked(db.updateTaskPointcloudFileName).mockImplementation(async (id, name) => ({
...taskPointcloud,
id,
name,
}));
vi.mocked(db.getModelPointcloudFileById).mockResolvedValue(modelPointcloud);
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValue(undefined);
vi.mocked(db.updateModelPointcloudFileName).mockImplementation(async (id, name) => ({
...modelPointcloud,
id,
name,
}));
});
it("任务点云去除首尾空格并按实际路径保留扩展名", async () => {
const result = await caller().pointcloudFile.rename({ id: 11, baseName: " 新名称 " });
expect(result?.name).toBe("新名称.PLY");
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "新名称.PLY", 11);
expect(db.updateTaskPointcloudFileName).toHaveBeenCalledWith(11, "新名称.PLY");
});
it("车型库点云按 modelId 检查重名并返回更新记录", async () => {
const result = await caller("super_admin").modelPointcloudFile.rename({ id: 22, baseName: "扫描结果" });
expect(result?.name).toBe("扫描结果.txt");
expect(db.findModelPointcloudFileByModelAndName).toHaveBeenCalledWith(202, "扫描结果.txt", 22);
expect(db.updateModelPointcloudFileName).toHaveBeenCalledWith(22, "扫描结果.txt");
});
it.each(["", " "])("拒绝空名称 %#", async (baseName) => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "点云名称不能为空",
});
});
it.each(["目录/名称", "目录\\名称", "名称\n换行"])("拒绝非法名称 %s", async (baseName) => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName })).rejects.toMatchObject({
code: "BAD_REQUEST",
});
});
it("拒绝拼接扩展名后超过 255 个字符的名称", async () => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "名".repeat(252) })).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "点云名称不能超过 255 个字符(含扩展名)",
});
});
it("同一任务中忽略英文大小写禁止重名", async () => {
vi.mocked(db.findTaskPointcloudFileByTaskAndName).mockResolvedValueOnce({
...taskPointcloud,
id: 12,
name: "NEW-NAME.ply",
});
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "new-name" })).rejects.toMatchObject({
code: "CONFLICT",
message: "当前任务下已存在同名点云",
});
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
});
it("同一车型中忽略英文大小写禁止重名", async () => {
vi.mocked(db.findModelPointcloudFileByModelAndName).mockResolvedValueOnce({
...modelPointcloud,
id: 23,
name: "SCAN.txt",
});
await expect(caller().modelPointcloudFile.rename({ id: 22, baseName: "scan" })).rejects.toMatchObject({
code: "CONFLICT",
message: "当前车型下已存在同名点云",
});
});
it("记录不存在时返回 NOT_FOUND", async () => {
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(undefined);
vi.mocked(db.getModelPointcloudFileById).mockResolvedValueOnce(undefined);
await expect(caller().pointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(caller().modelPointcloudFile.rename({ id: 999, baseName: "名称" })).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("名称未变化时直接成功且不查询重名、不更新", async () => {
const unchanged = { ...taskPointcloud, name: "原始点云.PLY" };
vi.mocked(db.getTaskPointcloudFileById).mockResolvedValueOnce(unchanged);
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toEqual(unchanged);
expect(db.findTaskPointcloudFileByTaskAndName).not.toHaveBeenCalled();
expect(db.updateTaskPointcloudFileName).not.toHaveBeenCalled();
});
it("名称只改变大小写时排除自身后允许更新", async () => {
await expect(caller().pointcloudFile.rename({ id: 11, baseName: "原始点云" })).resolves.toMatchObject({
name: "原始点云.PLY",
});
expect(db.findTaskPointcloudFileByTaskAndName).toHaveBeenCalledWith(101, "原始点云.PLY", 11);
});
it("普通用户无权调用,管理员和超级管理员可以调用", async () => {
await expect(caller("user").pointcloudFile.rename({ id: 11, baseName: "无权修改" })).rejects.toMatchObject({
code: "FORBIDDEN",
});
await expect(caller("admin").pointcloudFile.rename({ id: 11, baseName: "管理员修改" })).resolves.toMatchObject({
name: "管理员修改.PLY",
});
await expect(caller("super_admin").pointcloudFile.rename({ id: 11, baseName: "超管修改" })).resolves.toMatchObject({
name: "超管修改.PLY",
});
});
});

View File

@@ -0,0 +1,60 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { samplePointcloudFile } from './pointcloudSample';
const temporaryDirectories: string[] = [];
afterEach(() => {
while (temporaryDirectories.length > 0) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true });
}
});
function writeBinaryPly(properties: string[], records: number[][]): string {
const directory = mkdtempSync(join(tmpdir(), 'pointcloud-sample-'));
temporaryDirectories.push(directory);
const filePath = join(directory, 'cloud.ply');
const header = Buffer.from([
'ply',
'format binary_little_endian 1.0',
`element vertex ${records.length}`,
...properties.map((name) => `property float ${name}`),
'end_header',
'',
].join('\n'), 'ascii');
const body = Buffer.alloc(records.length * properties.length * 4);
records.forEach((record, recordIndex) => {
record.forEach((value, propertyIndex) => {
body.writeFloatLE(value, (recordIndex * properties.length + propertyIndex) * 4);
});
});
writeFileSync(filePath, Buffer.concat([header, body]));
return filePath;
}
describe('samplePointcloudFile', () => {
it('不会为只有 xyz 的输入点云伪造误差', async () => {
const filePath = writeBinaryPly(
['x', 'y', 'z'],
[[1, 2, 3], [4, 5, 6]],
);
const points = await samplePointcloudFile(filePath, 1_000, 2);
expect(points).toHaveLength(2);
expect(points.every((point) => point.error === undefined)).toBe(true);
});
it.each(['error', 'deviation'])('保留 PLY 中真实的 %s 属性', async (errorProperty) => {
const filePath = writeBinaryPly(
['x', 'y', 'z', errorProperty],
[[1, 2, 3, 0.25], [4, 5, 6, -0.5]],
);
const points = await samplePointcloudFile(filePath, 1_000, 2);
expect(points.map((point) => point.error)).toEqual([0.25, -0.5]);
});
});

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
export type SampledPoint = { x: number; y: number; z: number; error: number; r?: number; g?: number; b?: number };
export type SampledPoint = { x: number; y: number; z: number; error?: number; r?: number; g?: number; b?: number };
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
export async function randomSeekSample(
@@ -66,9 +66,10 @@ export async function randomSeekSample(
const y = parseFloat(parts[1]);
const z = parseFloat(parts[2]);
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
if (isNaN(error)) error = Math.abs(Math.sin(x * 0.5) * Math.cos(z * 0.5)) * 1.5;
points.push({ x, y, z, error });
const parsedError = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
const point: SampledPoint = { x, y, z };
if (Number.isFinite(parsedError)) point.error = parsedError;
points.push(point);
}
} finally {
fs.closeSync(fd);
@@ -173,8 +174,10 @@ function samplePlyBinary(
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
let xOff = -1, yOff = -1, zOff = -1;
let rOff = -1, gOff = -1, bOff = -1;
let errorOff = -1;
let xType = 'float', yType = 'float', zType = 'float';
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
let errorType = 'float';
let byteOff = 0;
for (const p of properties) {
const n = p.name.toLowerCase();
@@ -184,6 +187,7 @@ function samplePlyBinary(
else if (n === 'red') { rOff = byteOff; rType = p.type; }
else if (n === 'green') { gOff = byteOff; gType = p.type; }
else if (n === 'blue') { bOff = byteOff; bType = p.type; }
else if (n === 'error' || n === 'deviation') { errorOff = byteOff; errorType = p.type; }
byteOff += p.byteSize;
}
@@ -228,8 +232,11 @@ function samplePlyBinary(
const y = readNum(recBuf, yOff, yType);
const z = readNum(recBuf, zOff, zType);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
const pt: SampledPoint = { x, y, z, error };
const pt: SampledPoint = { x, y, z };
if (errorOff >= 0) {
const error = readNum(recBuf, errorOff, errorType);
if (Number.isFinite(error)) pt.error = error;
}
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
pt.r = readNum(recBuf, rOff, rType);
pt.g = readNum(recBuf, gOff, gType);
@@ -289,18 +296,22 @@ function samplePlyAscii(
if (!line) continue;
const parts = line.split(/\s+/);
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1;
let xi = -1, yi = -1, zi = -1, ri = -1, gi = -1, bi = -1, errorI = -1;
header.properties.forEach((p, i) => {
const n = p.name.toLowerCase();
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
else if (n === 'red') ri = i; else if (n === 'green') gi = i; else if (n === 'blue') bi = i;
else if (n === 'error' || n === 'deviation') errorI = i;
});
const x = parseFloat(parts[xi >= 0 ? xi : 0]);
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
const error = Math.abs(Math.sin(x * 0.001) * Math.cos(z * 0.001)) * 1.5;
const pt: SampledPoint = { x, y, z, error };
const pt: SampledPoint = { x, y, z };
if (errorI >= 0) {
const error = parseFloat(parts[errorI]);
if (Number.isFinite(error)) pt.error = error;
}
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
pt.r = parseFloat(parts[ri]);
pt.g = parseFloat(parts[gi]);

View File

@@ -1,217 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { appRouter } from "./routers";
import type { TrpcContext } from "./_core/context";
// Mock database functions
vi.mock("./db", () => ({
createProject: vi.fn().mockResolvedValue({
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "uploading",
totalPoints: 0,
totalChunks: 10,
uploadedChunks: 0,
totalSize: 0,
lodLevels: 0,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
getProjectsByUserId: vi.fn().mockResolvedValue([
{
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "ready",
totalPoints: 1000000,
totalChunks: 10,
uploadedChunks: 10,
totalSize: 32000000,
lodLevels: 8,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
getProjectById: vi.fn().mockResolvedValue({
id: 1,
userId: 1,
name: "Test Project",
description: "Test Description",
status: "ready",
totalPoints: 1000000,
totalChunks: 10,
uploadedChunks: 10,
totalSize: 32000000,
lodLevels: 8,
boundingBox: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
updateProject: vi.fn().mockResolvedValue(undefined),
createAnalysisResult: vi.fn().mockResolvedValue({
id: 1,
projectId: 1,
cadModelId: null,
type: "error_analysis",
status: "pending",
resultData: null,
errorStats: null,
measurements: null,
createdAt: new Date(),
updatedAt: new Date(),
}),
updateAnalysisResult: vi.fn().mockResolvedValue(undefined),
}));
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): TrpcContext {
const user: AuthenticatedUser = {
id: 1,
openId: "test-user",
email: "test@example.com",
name: "Test User",
loginMethod: "manus",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
return {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: vi.fn(),
} as unknown as TrpcContext["res"],
};
}
describe("project router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a new project", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.create({
name: "Test Project",
description: "Test Description",
totalChunks: 10,
});
expect(result).toBeDefined();
expect(result.name).toBe("Test Project");
expect(result.status).toBe("uploading");
});
it("lists user projects", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.list();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
expect(result[0].name).toBe("Test Project");
});
it("gets a project by id", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.get({ id: 1 });
expect(result).toBeDefined();
expect(result?.id).toBe(1);
expect(result?.name).toBe("Test Project");
});
it("updates a project", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.project.update({
id: 1,
name: "Updated Project",
status: "ready",
});
expect(result).toEqual({ success: true });
});
});
describe("analysis router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates an analysis result", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.create({
projectId: 1,
type: "error_analysis",
});
expect(result).toBeDefined();
expect(result.type).toBe("error_analysis");
expect(result.status).toBe("pending");
});
it("runs error analysis simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runErrorAnalysis({
analysisId: 1,
projectId: 1,
});
expect(result.success).toBe(true);
expect(result.errorStats).toBeDefined();
expect(result.errorStats.min).toBeDefined();
expect(result.errorStats.max).toBeDefined();
expect(result.errorStats.mean).toBeDefined();
});
it("runs measurement simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runMeasurement({
analysisId: 1,
projectId: 1,
});
expect(result.success).toBe(true);
expect(result.measurements).toBeDefined();
expect(Array.isArray(result.measurements)).toBe(true);
expect(result.measurements.length).toBeGreaterThan(0);
});
it("runs compression simulation", async () => {
const ctx = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.analysis.runCompression({
analysisId: 1,
projectId: 1,
compressionRatio: 0.3,
});
expect(result.success).toBe(true);
expect(result.resultData).toBeDefined();
expect(result.resultData.compressionRatio).toBe(0.3);
});
});

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

View File

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
vi.mock("./db", () => ({ getUserById: vi.fn() }));
import * as db from "./db";
import { authenticateRequest, createSessionToken } from "./_core/auth";
import { ENV } from "./_core/env";
import { COOKIE_NAME } from "../shared/const";
function user(overrides: Partial<User> = {}): User {
const now = new Date();
return { id: 7, username: "session-user", passwordHash: "unused", name: "Session User", email: null, role: "user", isActive: 1, mustChangePassword: 0, sessionVersion: 3, createdAt: now, updatedAt: now, lastLoginAt: null, ...overrides };
}
function requestWith(token: string) {
return { headers: { cookie: `${COOKIE_NAME}=${token}` } } as Parameters<typeof authenticateRequest>[0];
}
describe("会话即时失效", () => {
beforeEach(() => {
vi.clearAllMocks();
ENV.cookieSecret = "test-session-secret-with-at-least-32-chars";
});
it("有效账号与匹配版本可恢复会话", async () => {
const current = user();
vi.mocked(db.getUserById).mockResolvedValue(current);
const token = await createSessionToken(current);
await expect(authenticateRequest(requestWith(token))).resolves.toMatchObject({ id: 7, sessionVersion: 3 });
});
it("改密或重置密码递增版本后旧会话立即失效", async () => {
const token = await createSessionToken(user());
vi.mocked(db.getUserById).mockResolvedValue(user({ sessionVersion: 4 }));
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
});
it("账号被禁用后旧会话立即失效", async () => {
const token = await createSessionToken(user());
vi.mocked(db.getUserById).mockResolvedValue(user({ isActive: 0 }));
await expect(authenticateRequest(requestWith(token))).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "../drizzle/schema";
import type { TrpcContext } from "./_core/context";
vi.mock("./db", () => ({
getUserById: vi.fn(),
countActiveSuperAdmins: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
}));
import * as db from "./db";
import { appRouter } from "./routers";
function makeUser(id: number, role: User["role"] = "super_admin"): User {
const now = new Date();
return { id, username: `user-${id}`, passwordHash: "unused", name: `User ${id}`, email: null, role, isActive: 1, mustChangePassword: 0, sessionVersion: 0, createdAt: now, updatedAt: now, lastLoginAt: null };
}
function caller() {
const ctx: TrpcContext = { user: makeUser(1), req: {} as TrpcContext["req"], res: {} as TrpcContext["res"] };
return appRouter.createCaller(ctx);
}
describe("用户管理安全保护", () => {
beforeEach(() => vi.clearAllMocks());
it("禁止删除自己", async () => {
await expect(caller().users.delete({ id: 1 })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.deleteUser).not.toHaveBeenCalled();
});
it("禁止修改自己的角色", async () => {
vi.mocked(db.getUserById).mockResolvedValue(makeUser(1));
await expect(caller().users.update({ id: 1, role: "admin" })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
it("禁止禁用最后一个有效超级管理员", async () => {
vi.mocked(db.getUserById).mockResolvedValue(makeUser(2));
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(1);
await expect(caller().users.setActive({ id: 2, isActive: false })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
it("有多个超级管理员时允许禁用其中一个并使旧会话失效", async () => {
const target = makeUser(2);
vi.mocked(db.getUserById).mockResolvedValue(target);
vi.mocked(db.countActiveSuperAdmins).mockResolvedValue(2);
vi.mocked(db.updateUser).mockResolvedValue({ ...target, isActive: 0, sessionVersion: 1 });
await expect(caller().users.setActive({ id: 2, isActive: false })).resolves.toMatchObject({ isActive: 0, sessionVersion: 1 });
expect(db.updateUser).toHaveBeenCalledWith(2, { isActive: 0, sessionVersion: 1 });
});
it("遗留账号未修改用户名并重置密码前不能启用", async () => {
const legacy = { ...makeUser(2, "user"), username: "legacy_user_2", passwordHash: null, isActive: 0 };
vi.mocked(db.getUserById).mockResolvedValue(legacy);
await expect(caller().users.setActive({ id: 2, isActive: true })).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(db.updateUser).not.toHaveBeenCalled();
});
});