diff --git a/Dockerfile b/Dockerfile index 35e7900..66322d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,11 +34,7 @@ COPY . . # 设置构建时环境变量 ARG VITE_APP_TITLE="宏量点云处理系统" -ARG VITE_OAUTH_PORTAL_URL="disabled" -ARG VITE_APP_ID="" ENV VITE_APP_TITLE=$VITE_APP_TITLE -ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL -ENV VITE_APP_ID=$VITE_APP_ID # 构建应用 RUN pnpm build diff --git a/client/src/App.tsx b/client/src/App.tsx index 6f24bf4..e9774a8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,16 +1,33 @@ import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import NotFound from "@/pages/NotFound"; -import { Route, Switch } from "wouter"; +import { Redirect, Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; import Home from "./pages/Home"; +import Login from "./pages/Login"; +import ChangePassword from "./pages/ChangePassword"; +import Users from "./pages/Users"; +import { useAuth } from "./_core/hooks/useAuth"; +import { DashboardLayoutSkeleton } from "./components/DashboardLayoutSkeleton"; + +function RequireAuth({ children, superAdmin = false }: { children: React.ReactNode; superAdmin?: boolean }) { + const { user, loading } = useAuth(); + if (loading) return ; + if (!user) return ; + if (user.mustChangePassword === 1) return ; + if (superAdmin && user.role !== "super_admin") return ; + return <>{children}>; +} function Router() { // make sure to consider if you need authentication for certain routes return ( - + + + {() => } + {() => } {/* Final fallback route */} diff --git a/client/src/_core/hooks/useAuth.ts b/client/src/_core/hooks/useAuth.ts index dcef9bd..046c086 100644 --- a/client/src/_core/hooks/useAuth.ts +++ b/client/src/_core/hooks/useAuth.ts @@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) { const logoutMutation = trpc.auth.logout.useMutation({ onSuccess: () => { - utils.auth.me.setData(undefined, null); + utils.auth.me.setData(undefined, undefined); }, }); @@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) { } throw error; } finally { - utils.auth.me.setData(undefined, null); + utils.auth.me.setData(undefined, undefined); await utils.auth.me.invalidate(); } }, [logoutMutation, utils]); const state = useMemo(() => { - localStorage.setItem( - "manus-runtime-user-info", - JSON.stringify(meQuery.data) - ); return { user: meQuery.data ?? null, loading: meQuery.isLoading || logoutMutation.isPending, error: meQuery.error ?? logoutMutation.error ?? null, isAuthenticated: Boolean(meQuery.data), + canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin", + isSuperAdmin: meQuery.data?.role === "super_admin", }; }, [ meQuery.data, @@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) { if (typeof window === "undefined") return; if (window.location.pathname === redirectPath) return; - window.location.href = redirectPath + window.location.href = redirectPath; }, [ redirectOnUnauthenticated, redirectPath, diff --git a/client/src/const.ts b/client/src/const.ts index 1b7c529..6358f04 100644 --- a/client/src/const.ts +++ b/client/src/const.ts @@ -1,23 +1,2 @@ -export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; - -// Generate login URL at runtime so redirect URI reflects the current origin. -export const getLoginUrl = () => { - const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL; - const appId = import.meta.env.VITE_APP_ID; - - // 本地认证模式:OAuth 未配置时回到首页(本地模式会自动登录) - if (!oauthPortalUrl || oauthPortalUrl === 'disabled') { - return '/'; - } - - const redirectUri = `${window.location.origin}/api/oauth/callback`; - const state = btoa(redirectUri); - - const url = new URL(`${oauthPortalUrl}/app-auth`); - url.searchParams.set("appId", appId || ''); - url.searchParams.set("redirectUri", redirectUri); - url.searchParams.set("state", state); - url.searchParams.set("type", "signIn"); - - return url.toString(); -}; +export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const"; +export const getLoginUrl = () => "/login"; diff --git a/client/src/main.tsx b/client/src/main.tsx index f7dd02f..0c3f07e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client"; import { createRoot } from "react-dom/client"; import superjson from "superjson"; +import { toast } from "sonner"; import App from "./App"; -import { getLoginUrl } from "./const"; import "./index.css"; // 禁用 HMR 失败时的自动页面刷新,保留热更新功能 @@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => { if (!isUnauthorized) return; - window.location.href = getLoginUrl(); + if (window.location.pathname !== "/login") window.location.href = "/login"; }; queryClient.getQueryCache().subscribe(event => { @@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => { if (event.type === "updated" && event.action.type === "error") { const error = event.mutation.state.error; redirectToLoginIfUnauthorized(error); + if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") { + toast.error(error.message || "无权执行此操作"); + } console.error("[API Mutation Error]", error); } }); diff --git a/client/src/pages/ChangePassword.tsx b/client/src/pages/ChangePassword.tsx new file mode 100644 index 0000000..b2304ee --- /dev/null +++ b/client/src/pages/ChangePassword.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import { Redirect, useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function ChangePassword() { + const { user, loading, logout } = useAuth(); + const [, setLocation] = useLocation(); + const utils = trpc.useUtils(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const change = trpc.auth.changePassword.useMutation({ + onSuccess: async () => { + await utils.auth.me.invalidate(); + setLocation("/"); + }, + }); + + if (!loading && !user) return ; + const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword; + + return ( + + { + event.preventDefault(); + if (!mismatch) change.mutate({ currentPassword, newPassword }); + }}> + + 修改密码 + + {user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"} + + + 当前密码 setCurrentPassword(e.target.value)} required /> + 新密码(至少 8 位) setNewPassword(e.target.value)} required /> + 确认新密码 setConfirmPassword(e.target.value)} required /> + {mismatch && 两次输入的新密码不一致} + {change.error && {change.error.message}} + {change.isPending ? "正在保存…" : "保存新密码"} + logout().then(() => setLocation("/login"))}>退出登录 + + + ); +} diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..24aa574 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,53 @@ +import { useState } from "react"; +import { Redirect, useLocation } from "wouter"; +import { trpc } from "@/lib/trpc"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function Login() { + const { user, loading } = useAuth(); + const [, setLocation] = useLocation(); + const utils = trpc.useUtils(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const login = trpc.auth.login.useMutation({ + onSuccess: async (signedInUser) => { + utils.auth.me.setData(undefined, signedInUser); + await utils.auth.me.invalidate(); + setLocation(signedInUser.mustChangePassword === 1 ? "/change-password" : "/"); + }, + }); + + if (!loading && user) return ; + + return ( + + { + event.preventDefault(); + login.mutate({ username, password }); + }} + > + + 宏量点云处理系统 + 使用本地账号登录 + + + 用户名 + setUsername(e.target.value)} required /> + + + 密码 + setPassword(e.target.value)} required /> + + {login.error && {login.error.message}} + + {login.isPending ? "正在登录…" : "登录"} + + + + ); +} diff --git a/client/src/pages/Users.tsx b/client/src/pages/Users.tsx new file mode 100644 index 0000000..3872951 --- /dev/null +++ b/client/src/pages/Users.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { useLocation } from "wouter"; +import { ArrowLeft, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +const roleNames = { super_admin: "超级管理员", admin: "管理员", user: "普通用户" } as const; +type Role = keyof typeof roleNames; + +export default function Users() { + const [, setLocation] = useLocation(); + const utils = trpc.useUtils(); + const [search, setSearch] = useState(""); + const [draftSearch, setDraftSearch] = useState(""); + const [form, setForm] = useState({ username: "", temporaryPassword: "", name: "", email: "", role: "user" as Role }); + const list = trpc.users.list.useQuery(search ? { search } : {}); + const refresh = () => utils.users.list.invalidate(); + const onError = (error: { message: string }) => toast.error(error.message); + const create = trpc.users.create.useMutation({ onSuccess: () => { toast.success("用户已创建"); setForm({ username: "", temporaryPassword: "", name: "", email: "", role: "user" }); void refresh(); }, onError }); + const update = trpc.users.update.useMutation({ onSuccess: () => { toast.success("用户信息已更新"); void refresh(); }, onError }); + const setActive = trpc.users.setActive.useMutation({ onSuccess: () => { toast.success("账号状态已更新"); void refresh(); }, onError }); + const resetPassword = trpc.users.resetPassword.useMutation({ onSuccess: () => toast.success("临时密码已重置,旧会话已失效"), onError }); + const remove = trpc.users.delete.useMutation({ onSuccess: () => { toast.success("用户已永久删除"); void refresh(); }, onError }); + + return ( + + + setLocation("/")}>用户管理创建账号、分配角色并管理登录状态 + + + + 新建用户 + { event.preventDefault(); create.mutate(form); }}> + 用户名 setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /> + 姓名 setForm(v => ({ ...v, name: e.target.value }))} required /> + 邮箱(可选) setForm(v => ({ ...v, email: e.target.value }))} /> + 临时密码 setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /> + setForm(v => ({ ...v, role }))}>普通用户管理员超级管理员创建 + + + + + { event.preventDefault(); setSearch(draftSearch.trim()); }}> setDraftSearch(e.target.value)} />搜索 + + + 用户角色状态首次改密最后登录操作 + {list.data?.map(user => ( + + {user.name || "未命名"}{user.username}{user.email ? ` · ${user.email}` : ""} + update.mutate({ id: user.id, role })}>普通用户管理员超级管理员 + setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"} + {user.mustChangePassword === 1 ? "是" : "否"} + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"} + { const username = window.prompt("用户名", user.username); if (username == null) return; const name = window.prompt("姓名", user.name || ""); if (name == null) return; const email = window.prompt("邮箱(可留空)", user.email || ""); if (email == null) return; update.mutate({ id: user.id, username, name, email }); }}>编辑 { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码 { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}> + + ))} + + {list.isLoading && 正在加载…} + {list.error && {list.error.message}} + + + + ); +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1639524..1839c6f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -44,16 +44,15 @@ services: - NODE_ENV=development - PORT=3002 - DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db - - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} - - AUTH_MODE=local + - JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥} + - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名} + - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码} - S3_ENDPOINT=http://localhost:9010 - S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin} - S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123} - S3_BUCKET=pointcloud-data - S3_REGION=us-east-1 - VITE_APP_TITLE=宏量点云处理系统 - - VITE_OAUTH_PORTAL_URL=disabled - - VITE_APP_ID= # 数据目录路径 - DATA_DIR=/data # 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用) diff --git a/docker-compose.yml b/docker-compose.yml index f4163ea..1ae3697 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,9 +28,9 @@ services: - NODE_ENV=production - PORT=3002 - DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db - - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} - # 内网部署禁用OAuth,使用本地认证 - - AUTH_MODE=local + - JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥} + - BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名} + - BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码} # MinIO S3兼容存储配置 - S3_ENDPOINT=http://localhost:9010 - S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin} diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql index abfe6bc..43ecfb4 100644 --- a/docker/mysql/init.sql +++ b/docker/mysql/init.sql @@ -16,15 +16,17 @@ USE pointcloud_db; -- 用户表 CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, - openId VARCHAR(64) NOT NULL UNIQUE, + username VARCHAR(64) NOT NULL UNIQUE, + passwordHash VARCHAR(255), name TEXT, email VARCHAR(320), - loginMethod VARCHAR(64), - role ENUM('user', 'admin') NOT NULL DEFAULT 'user', + role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user', + isActive INT NOT NULL DEFAULT 1, + mustChangePassword INT NOT NULL DEFAULT 1, + sessionVersion INT NOT NULL DEFAULT 0, createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_openId (openId), + lastLoginAt TIMESTAMP NULL, INDEX idx_email (email(255)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -124,6 +126,22 @@ CREATE TABLE IF NOT EXISTS `model_cad_files` ( `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `model_pointcloud_files` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `modelId` INT NOT NULL, + `name` VARCHAR(255) NOT NULL, + `fileSize` BIGINT NOT NULL, + `filePath` VARCHAR(512) NOT NULL, + `metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending', + `pointCount` BIGINT NULL, + `pointAttributes` JSON NULL, + `pointDataFormat` VARCHAR(32) NULL, + `bytesPerPoint` INT NULL, + `metadataError` TEXT NULL, + `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_model_pointcloud_modelId` (`modelId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `inspection_tasks` ( `id` INT AUTO_INCREMENT PRIMARY KEY, `modelId` INT NOT NULL, @@ -143,6 +161,16 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` ( `name` VARCHAR(255) NOT NULL, `fileSize` BIGINT NOT NULL, `filePath` VARCHAR(512) NOT NULL, + `source` VARCHAR(32) NOT NULL DEFAULT 'uploaded', + `sourceResultId` INT NULL, + `sourcePointcloudFileId` INT NULL, + `sourceModelPointcloudId` INT NULL, + `metadataStatus` ENUM('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending', + `pointCount` BIGINT NULL, + `pointAttributes` JSON NULL, + `pointDataFormat` VARCHAR(32) NULL, + `bytesPerPoint` INT NULL, + `metadataError` TEXT NULL, `createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -158,10 +186,5 @@ CREATE TABLE IF NOT EXISTS `task_analysis_results` ( `updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- 创建默认管理员用户 (内网部署使用) -INSERT INTO users (openId, name, email, role) VALUES - ('admin-local', '系统管理员', 'admin@localhost', 'admin') -ON DUPLICATE KEY UPDATE name = VALUES(name); - -- 输出初始化完成信息 SELECT 'Database initialization completed successfully!' AS message; diff --git a/drizzle/0015_local_users_and_roles.sql b/drizzle/0015_local_users_and_roles.sql new file mode 100644 index 0000000..af4f54f --- /dev/null +++ b/drizzle/0015_local_users_and_roles.sql @@ -0,0 +1,22 @@ +ALTER TABLE `users` + MODIFY COLUMN `role` ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user', + ADD COLUMN `username` VARCHAR(64) NULL AFTER `id`, + ADD COLUMN `passwordHash` VARCHAR(255) NULL AFTER `username`, + ADD COLUMN `isActive` INT NOT NULL DEFAULT 0 AFTER `role`, + ADD COLUMN `mustChangePassword` INT NOT NULL DEFAULT 1 AFTER `isActive`, + ADD COLUMN `sessionVersion` INT NOT NULL DEFAULT 0 AFTER `mustChangePassword`, + ADD COLUMN `lastLoginAt` TIMESTAMP NULL AFTER `updatedAt`; + +UPDATE `users` +SET `username` = CONCAT('legacy_user_', `id`), + `isActive` = 0, + `mustChangePassword` = 1, + `sessionVersion` = `sessionVersion` + 1; + +ALTER TABLE `users` + MODIFY COLUMN `username` VARCHAR(64) NOT NULL, + ADD UNIQUE INDEX `users_username_unique` (`username`), + DROP INDEX `users_openId_unique`, + DROP COLUMN `openId`, + DROP COLUMN `loginMethod`, + DROP COLUMN `lastSignedIn`; diff --git a/drizzle/0016_pointcloud_metadata.sql b/drizzle/0016_pointcloud_metadata.sql new file mode 100644 index 0000000..ff3da66 --- /dev/null +++ b/drizzle/0016_pointcloud_metadata.sql @@ -0,0 +1,15 @@ +ALTER TABLE `model_pointcloud_files` + ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending', + ADD COLUMN `pointCount` bigint NULL, + ADD COLUMN `pointAttributes` json NULL, + ADD COLUMN `pointDataFormat` varchar(32) NULL, + ADD COLUMN `bytesPerPoint` int NULL, + ADD COLUMN `metadataError` text NULL; + +ALTER TABLE `task_pointcloud_files` + ADD COLUMN `metadataStatus` enum('pending','ready','unsupported','error') NOT NULL DEFAULT 'pending', + ADD COLUMN `pointCount` bigint NULL, + ADD COLUMN `pointAttributes` json NULL, + ADD COLUMN `pointDataFormat` varchar(32) NULL, + ADD COLUMN `bytesPerPoint` int NULL, + ADD COLUMN `metadataError` text NULL; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 6a701fe..f1ad6f5 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,17 +1,21 @@ import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core"; +import type { PointAttribute } from "../shared/pointcloudMetadata"; -// ─── 用户表(保持不变) ──────────────────────────────────────────────────────── +// ─── 本地用户 ────────────────────────────────────────────────────────────────── export const users = mysqlTable("users", { id: int("id").autoincrement().primaryKey(), - openId: varchar("openId", { length: 64 }).notNull().unique(), + username: varchar("username", { length: 64 }).notNull().unique(), + passwordHash: varchar("passwordHash", { length: 255 }), name: text("name"), email: varchar("email", { length: 320 }), - loginMethod: varchar("loginMethod", { length: 64 }), - role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(), + isActive: int("isActive").default(1).notNull(), + mustChangePassword: int("mustChangePassword").default(1).notNull(), + sessionVersion: int("sessionVersion").default(0).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), - lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), + lastLoginAt: timestamp("lastLoginAt"), }); export type User = typeof users.$inferSelect; @@ -71,6 +75,12 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", { name: varchar("name", { length: 255 }).notNull(), fileSize: bigint("fileSize", { mode: "number" }).notNull(), filePath: varchar("filePath", { length: 512 }).notNull(), + metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(), + pointCount: bigint("pointCount", { mode: "number" }), + pointAttributes: json("pointAttributes").$type(), + pointDataFormat: varchar("pointDataFormat", { length: 32 }), + bytesPerPoint: int("bytesPerPoint"), + metadataError: text("metadataError"), createdAt: timestamp("createdAt").defaultNow().notNull(), }); @@ -136,6 +146,12 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", { sourcePointcloudFileId: int("sourcePointcloudFileId"), /** 来源车型点云文件 ID(仅 model_library 时有值) */ sourceModelPointcloudId: int("sourceModelPointcloudId"), + metadataStatus: mysqlEnum("metadataStatus", ["pending", "ready", "unsupported", "error"]).default("pending").notNull(), + pointCount: bigint("pointCount", { mode: "number" }), + pointAttributes: json("pointAttributes").$type(), + pointDataFormat: varchar("pointDataFormat", { length: 32 }), + bytesPerPoint: int("bytesPerPoint"), + metadataError: text("metadataError"), createdAt: timestamp("createdAt").defaultNow().notNull(), }); diff --git a/server/_core/auth.ts b/server/_core/auth.ts new file mode 100644 index 0000000..5acbb77 --- /dev/null +++ b/server/_core/auth.ts @@ -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) { + 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 { + 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(); +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; +} diff --git a/server/_core/context.ts b/server/_core/context.ts index e4ae108..9a3337e 100644 --- a/server/_core/context.ts +++ b/server/_core/context.ts @@ -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; diff --git a/server/_core/cookies.ts b/server/_core/cookies.ts index 424872c..9c1d1c1 100644 --- a/server/_core/cookies.ts +++ b/server/_core/cookies.ts @@ -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), }; } diff --git a/server/_core/env.ts b/server/_core/env.ts index 202a480..3d0e221 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -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 ?? "", diff --git a/server/_core/index.ts b/server/_core/index.ts index 4b4dffd..846d70e 100644 --- a/server/_core/index.ts +++ b/server/_core/index.ts @@ -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 { } 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'); diff --git a/server/_core/oauth.ts b/server/_core/oauth.ts deleted file mode 100644 index fd45373..0000000 --- a/server/_core/oauth.ts +++ /dev/null @@ -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" }); - } - }); -} diff --git a/server/_core/sdk.ts b/server/_core/sdk.ts deleted file mode 100644 index c4bf66b..0000000 --- a/server/_core/sdk.ts +++ /dev/null @@ -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) { - 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 { - const payload: ExchangeTokenRequest = { - clientId: ENV.appId, - grantType: "authorization_code", - code, - redirectUri: this.decodeState(state), - }; - - const { data } = await this.client.post( - EXCHANGE_TOKEN_PATH, - payload - ); - - return data; - } - - async getUserInfoByToken( - token: ExchangeTokenResponse - ): Promise { - const { data } = await this.client.post( - 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( - 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 { - 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 { - 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(); - } - - 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 { - return this.signSession( - { - openId, - appId: ENV.appId, - name: options.name || "", - }, - options - ); - } - - async signSession( - payload: SessionPayload, - options: { expiresInMs?: number } = {} - ): Promise { - 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; - - 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 { - const payload: GetUserInfoWithJwtRequest = { - jwtToken, - projectId: ENV.appId, - }; - - const { data } = await this.client.post( - 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 { - 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(); diff --git a/server/_core/trpc.ts b/server/_core/trpc.ts index a0bcc05..29a7f2a 100644 --- a/server/_core/trpc.ts +++ b/server/_core/trpc.ts @@ -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().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 } }); + }), +); diff --git a/server/_core/types/manusTypes.ts b/server/_core/types/manusTypes.ts deleted file mode 100644 index 89a3819..0000000 --- a/server/_core/types/manusTypes.ts +++ /dev/null @@ -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; -} diff --git a/server/analysisArtifacts.test.ts b/server/analysisArtifacts.test.ts new file mode 100644 index 0000000..87f39a6 --- /dev/null +++ b/server/analysisArtifacts.test.ts @@ -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(); + }); +}); diff --git a/server/analysisArtifacts.ts b/server/analysisArtifacts.ts new file mode 100644 index 0000000..7627386 --- /dev/null +++ b/server/analysisArtifacts.ts @@ -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 { + 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 }), + ), + ); +} diff --git a/server/analysisResultDeletion.test.ts b/server/analysisResultDeletion.test.ts new file mode 100644 index 0000000..284fde2 --- /dev/null +++ b/server/analysisResultDeletion.test.ts @@ -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>; + +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(); + }); +}); diff --git a/server/analysisResultDeletion.ts b/server/analysisResultDeletion.ts new file mode 100644 index 0000000..6515906 --- /dev/null +++ b/server/analysisResultDeletion.ts @@ -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 }; +} diff --git a/server/auth.logout.test.ts b/server/auth.logout.test.ts index 3071539..37fdc3a 100644 --- a/server/auth.logout.test.ts +++ b/server/auth.logout.test.ts @@ -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: "/", }); diff --git a/server/auth.security.test.ts b/server/auth.security.test.ts new file mode 100644 index 0000000..c95703a --- /dev/null +++ b/server/auth.security.test.ts @@ -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); + }); +}); diff --git a/server/bootstrap-admin.test.ts b/server/bootstrap-admin.test.ts new file mode 100644 index 0000000..47c2d16 --- /dev/null +++ b/server/bootstrap-admin.test.ts @@ -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"); + }); +}); diff --git a/server/db.ts b/server/db.ts index 768bd48..97c5136 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,6 +1,7 @@ -import { eq, desc, inArray, sql, and } from "drizzle-orm"; +import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import type { ResultSetHeader } from "mysql2"; +import { inspectPointcloudMetadata } from "./pointcloudMetadata.js"; import { users, InsertUser, vehicleSeries, InsertVehicleSeries, VehicleSeries, @@ -12,7 +13,6 @@ import { taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile, taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult, } from "../drizzle/schema"; -import { ENV } from './_core/env'; let _db: ReturnType | null = null; @@ -80,60 +80,67 @@ export async function getDb() { // ─── 用户 ───────────────────────────────────────────────────────────────────── -export async function upsertUser(user: InsertUser): Promise { - 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 = {}; - - 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) { + 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`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 { @@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile) return row; } +const modelMetadataBackfills = new Map>(); + +async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise { + 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 { 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 { @@ -255,6 +290,36 @@ export async function getModelPointcloudFileById(id: number): Promise { + 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 { + 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 { const db = await getDb(); if (!db) return; @@ -448,6 +513,7 @@ export async function createRegisteredPointcloudFile( ): Promise { 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>(); + +async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise { + 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 { 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 { + 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 { + 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 { + 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 { @@ -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 { if (!db) return; await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id)); } + +/** + * 删除分析结果及其派生的已配准点云记录。 + * 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。 + */ +export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise { + 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)); + }); +} diff --git a/server/fileRoutes.ts b/server/fileRoutes.ts index 6a7f584..7767ab8 100644 --- a/server/fileRoutes.ts +++ b/server/fileRoutes.ts @@ -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)[0] || (req.params as Record)['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({ diff --git a/server/inspectionTask.test.ts b/server/inspectionTask.test.ts index 5de7049..9ec3f5f 100644 --- a/server/inspectionTask.test.ts +++ b/server/inspectionTask.test.ts @@ -23,14 +23,17 @@ type AuthenticatedUser = NonNullable; 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 { diff --git a/server/permissions.test.ts b/server/permissions.test.ts new file mode 100644 index 0000000..2b52e2f --- /dev/null +++ b/server/permissions.test.ts @@ -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"); + }); +}); diff --git a/server/pointcloudMetadata.test.ts b/server/pointcloudMetadata.test.ts new file mode 100644 index 0000000..46b270b --- /dev/null +++ b/server/pointcloudMetadata.test.ts @@ -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"); + }); +}); diff --git a/server/pointcloudMetadata.ts b/server/pointcloudMetadata.ts new file mode 100644 index 0000000..b88212d --- /dev/null +++ b/server/pointcloudMetadata.ts @@ -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 = { + 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 文件头解析失败", + }; + } +} diff --git a/server/pointcloudRename.test.ts b/server/pointcloudRename.test.ts new file mode 100644 index 0000000..8810c5a --- /dev/null +++ b/server/pointcloudRename.test.ts @@ -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", + }); + }); +}); diff --git a/server/pointcloudSample.test.ts b/server/pointcloudSample.test.ts new file mode 100644 index 0000000..17502a2 --- /dev/null +++ b/server/pointcloudSample.test.ts @@ -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]); + }); +}); diff --git a/server/pointcloudSample.ts b/server/pointcloudSample.ts index 2593e7c..498e3d5 100644 --- a/server/pointcloudSample.ts +++ b/server/pointcloudSample.ts @@ -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]); diff --git a/server/project.test.ts b/server/project.test.ts deleted file mode 100644 index 50fe7fc..0000000 --- a/server/project.test.ts +++ /dev/null @@ -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; - -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); - }); -}); diff --git a/server/routers.ts b/server/routers.ts index c51db56..bde0cf1 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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); }), }), diff --git a/server/session.security.test.ts b/server/session.security.test.ts new file mode 100644 index 0000000..387125b --- /dev/null +++ b/server/session.security.test.ts @@ -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 { + 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[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(); + }); +}); diff --git a/server/users.security.test.ts b/server/users.security.test.ts new file mode 100644 index 0000000..d7c3998 --- /dev/null +++ b/server/users.security.test.ts @@ -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(); + }); +}); diff --git a/shared/const.ts b/shared/const.ts index 112ffc5..2c0261c 100644 --- a/shared/const.ts +++ b/shared/const.ts @@ -1,5 +1,5 @@ export const COOKIE_NAME = "app_session_id"; -export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365; -export const AXIOS_TIMEOUT_MS = 30_000; -export const UNAUTHED_ERR_MSG = 'Please login (10001)'; -export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)'; +export const SESSION_TTL_MS = 1000 * 60 * 60 * 24; +export const UNAUTHED_ERR_MSG = '请先登录'; +export const NOT_ADMIN_ERR_MSG = '无权执行此操作'; +export const MUST_CHANGE_PASSWORD_ERR_MSG = '请先修改初始密码'; diff --git a/shared/pointcloudMetadata.ts b/shared/pointcloudMetadata.ts new file mode 100644 index 0000000..f9a12d2 --- /dev/null +++ b/shared/pointcloudMetadata.ts @@ -0,0 +1,21 @@ +export type PointcloudMetadataStatus = "pending" | "ready" | "unsupported" | "error"; + +export type PointcloudDataFormat = + | "ascii" + | "binary_little_endian" + | "binary_big_endian"; + +export type PointAttribute = { + name: string; + type: string; + bytes: number; +}; + +export type PointcloudMetadataValues = { + metadataStatus: Exclude; + pointCount: number | null; + pointAttributes: PointAttribute[] | null; + pointDataFormat: PointcloudDataFormat | null; + bytesPerPoint: number | null; + metadataError: string | null; +}; diff --git a/vitest.config.ts b/vitest.config.ts index c549003..48591e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,6 +14,6 @@ export default defineConfig({ }, test: { environment: "node", - include: ["server/**/*.test.ts", "server/**/*.spec.ts"], + include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/src/**/*.test.ts", "client/src/**/*.spec.ts"], }, });
+ {user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"} +
两次输入的新密码不一致
{change.error.message}
使用本地账号登录
{login.error.message}
创建账号、分配角色并管理登录状态
正在加载…
{list.error.message}