feat: add local access control and managed point clouds

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

View File

@@ -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

View File

@@ -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 <DashboardLayoutSkeleton />;
if (!user) return <Redirect to="/login" />;
if (user.mustChangePassword === 1) return <Redirect to="/change-password" />;
if (superAdmin && user.role !== "super_admin") return <Redirect to="/" />;
return <>{children}</>;
}
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path="/login" component={Login} />
<Route path="/change-password" component={ChangePassword} />
<Route path="/users">{() => <RequireAuth superAdmin><Users /></RequireAuth>}</Route>
<Route path="/">{() => <RequireAuth><Home /></RequireAuth>}</Route>
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />

View File

@@ -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,

View File

@@ -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";

View File

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

View File

@@ -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 <Redirect to="/login" />;
const mismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form className="w-full max-w-sm industrial-panel p-8 space-y-5" onSubmit={event => {
event.preventDefault();
if (!mismatch) change.mutate({ currentPassword, newPassword });
}}>
<div>
<h1 className="text-xl font-semibold">修改密码</h1>
<p className="text-sm text-muted-foreground mt-1">
{user?.mustChangePassword === 1 ? "首次登录必须设置新密码后才能继续" : "修改当前账号密码"}
</p>
</div>
<div className="space-y-2"><Label htmlFor="current">当前密码</Label><Input id="current" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="next">新密码(至少 8 位)</Label><Input id="next" type="password" autoComplete="new-password" minLength={8} value={newPassword} onChange={e => setNewPassword(e.target.value)} required /></div>
<div className="space-y-2"><Label htmlFor="confirm">确认新密码</Label><Input id="confirm" type="password" autoComplete="new-password" minLength={8} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required /></div>
{mismatch && <p className="text-sm text-destructive">两次输入的新密码不一致</p>}
{change.error && <p className="text-sm text-destructive" role="alert">{change.error.message}</p>}
<Button className="w-full" type="submit" disabled={change.isPending || mismatch || newPassword.length < 8}>{change.isPending ? "正在保存…" : "保存新密码"}</Button>
<Button className="w-full" type="button" variant="ghost" onClick={() => logout().then(() => setLocation("/login"))}>退出登录</Button>
</form>
</div>
);
}

View File

@@ -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 <Redirect to={user.mustChangePassword === 1 ? "/change-password" : "/"} />;
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<form
className="w-full max-w-sm industrial-panel p-8 space-y-5"
onSubmit={(event) => {
event.preventDefault();
login.mutate({ username, password });
}}
>
<div>
<h1 className="text-xl font-semibold">宏量点云处理系统</h1>
<p className="text-sm text-muted-foreground mt-1">使用本地账号登录</p>
</div>
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Input id="username" autoComplete="username" autoFocus value={username} onChange={e => setUsername(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input id="password" type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} required />
</div>
{login.error && <p className="text-sm text-destructive" role="alert">{login.error.message}</p>}
<Button className="w-full" type="submit" disabled={login.isPending || !username || !password}>
{login.isPending ? "正在登录…" : "登录"}
</Button>
</form>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-background p-6 space-y-6">
<header className="flex items-center justify-between">
<div className="flex items-center gap-3"><Button variant="ghost" size="icon" onClick={() => setLocation("/")}><ArrowLeft className="w-4 h-4" /></Button><div><h1 className="text-xl font-semibold">用户管理</h1><p className="text-sm text-muted-foreground">创建账号、分配角色并管理登录状态</p></div></div>
</header>
<section className="industrial-panel p-5">
<h2 className="font-medium mb-4">新建用户</h2>
<form className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end" onSubmit={event => { event.preventDefault(); create.mutate(form); }}>
<div className="space-y-1"><Label>用户名</Label><Input value={form.username} onChange={e => setForm(v => ({ ...v, username: e.target.value }))} minLength={3} required /></div>
<div className="space-y-1"><Label>姓名</Label><Input value={form.name} onChange={e => setForm(v => ({ ...v, name: e.target.value }))} required /></div>
<div className="space-y-1"><Label>邮箱(可选)</Label><Input type="email" value={form.email} onChange={e => setForm(v => ({ ...v, email: e.target.value }))} /></div>
<div className="space-y-1"><Label>临时密码</Label><Input type="password" minLength={8} value={form.temporaryPassword} onChange={e => setForm(v => ({ ...v, temporaryPassword: e.target.value }))} required /></div>
<div className="flex gap-2"><Select value={form.role} onValueChange={(role: Role) => setForm(v => ({ ...v, role }))}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select><Button type="submit" disabled={create.isPending}>创建</Button></div>
</form>
</section>
<section className="industrial-panel p-5 space-y-4">
<form className="flex gap-2 max-w-md" onSubmit={event => { event.preventDefault(); setSearch(draftSearch.trim()); }}><Input placeholder="搜索用户名、姓名或邮箱" value={draftSearch} onChange={e => setDraftSearch(e.target.value)} /><Button type="submit" variant="secondary"><Search className="w-4 h-4 mr-1" />搜索</Button></form>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="border-b text-left text-muted-foreground"><th className="p-2">用户</th><th className="p-2">角色</th><th className="p-2">状态</th><th className="p-2">首次改密</th><th className="p-2">最后登录</th><th className="p-2 text-right">操作</th></tr></thead>
<tbody>{list.data?.map(user => (
<tr key={user.id} className="border-b border-border/60">
<td className="p-2"><div className="font-medium">{user.name || "未命名"}</div><div className="text-xs text-muted-foreground">{user.username}{user.email ? ` · ${user.email}` : ""}</div></td>
<td className="p-2"><Select value={user.role} onValueChange={(role: Role) => update.mutate({ id: user.id, role })}><SelectTrigger className="w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="user">普通用户</SelectItem><SelectItem value="admin">管理员</SelectItem><SelectItem value="super_admin">超级管理员</SelectItem></SelectContent></Select></td>
<td className="p-2"><button className={user.isActive === 1 ? "text-green-400" : "text-muted-foreground"} onClick={() => setActive.mutate({ id: user.id, isActive: user.isActive !== 1 })}>{user.isActive === 1 ? "已启用" : "已禁用"}</button></td>
<td className="p-2">{user.mustChangePassword === 1 ? "是" : "否"}</td>
<td className="p-2 text-xs text-muted-foreground">{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "从未登录"}</td>
<td className="p-2"><div className="flex justify-end gap-1"><Button size="sm" variant="ghost" onClick={() => { 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 }); }}>编辑</Button><Button size="sm" variant="ghost" onClick={() => { const password = window.prompt("输入至少 8 位临时密码"); if (password) resetPassword.mutate({ id: user.id, temporaryPassword: password }); }}>重置密码</Button><Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`永久删除用户“${user.username}”?此操作不可撤销。`)) remove.mutate({ id: user.id }); }}><Trash2 className="w-4 h-4" /></Button></div></td>
</tr>
))}</tbody>
</table>
{list.isLoading && <p className="text-sm text-muted-foreground p-4">正在加载…</p>}
{list.error && <p className="text-sm text-destructive p-4">{list.error.message}</p>}
</div>
</section>
</div>
);
}

View File

@@ -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} 占位符引用)

View File

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

View File

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

View File

@@ -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`;

View File

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

View File

@@ -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<PointAttribute[]>(),
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<PointAttribute[]>(),
pointDataFormat: varchar("pointDataFormat", { length: 32 }),
bytesPerPoint: int("bytesPerPoint"),
metadataError: text("metadataError"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { eq, desc, inArray, sql, and } from "drizzle-orm";
import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import type { ResultSetHeader } from "mysql2";
import { inspectPointcloudMetadata } from "./pointcloudMetadata.js";
import {
users, InsertUser,
vehicleSeries, InsertVehicleSeries, VehicleSeries,
@@ -12,7 +13,6 @@ import {
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
} from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
@@ -80,60 +80,67 @@ export async function getDb() {
// ─── 用户 ─────────────────────────────────────────────────────────────────────
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) throw new Error("User openId is required for upsert");
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
}
try {
const values: InsertUser = { openId: user.openId };
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) values.lastSignedIn = new Date();
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
}
export async function getUserByOpenId(openId: string) {
export async function getUserById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
return result[0];
}
export async function getUserByUsername(username: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.username, username)).limit(1);
return result[0];
}
export async function listUsers(search?: string) {
const db = await getDb();
if (!db) return [];
const term = search?.trim();
if (!term) return db.select().from(users).orderBy(desc(users.createdAt));
const pattern = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
return db.select().from(users)
.where(or(like(users.username, pattern), like(users.name, pattern), like(users.email, pattern)))
.orderBy(desc(users.createdAt));
}
export async function createUser(data: InsertUser) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(users).values(data);
return getUserById(getMysqlInsertId(result));
}
export async function updateUser(id: number, data: Partial<InsertUser>) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users).set(data).where(eq(users.id, id));
return getUserById(id);
}
export async function incrementUserSessionVersion(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users)
.set({ sessionVersion: sql`${users.sessionVersion} + 1` })
.where(eq(users.id, id));
}
export async function deleteUser(id: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(users).where(eq(users.id, id));
}
export async function countActiveSuperAdmins() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users)
.where(and(eq(users.role, "super_admin"), eq(users.isActive, 1)));
return Number(row?.count ?? 0);
}
// ─── 车系 ─────────────────────────────────────────────────────────────────────
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
@@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile)
return row;
}
const modelMetadataBackfills = new Map<number, Promise<ModelPointcloudFile>>();
async function backfillModelPointcloudMetadata(row: ModelPointcloudFile): Promise<ModelPointcloudFile> {
if (row.metadataStatus !== "pending") return row;
const existing = modelMetadataBackfills.get(row.id);
if (existing) return existing;
const backfill = (async () => {
const db = await getDb();
if (!db) return row;
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
await db.update(modelPointcloudFiles)
.set(metadata)
.where(and(
eq(modelPointcloudFiles.id, row.id),
eq(modelPointcloudFiles.metadataStatus, "pending"),
));
return { ...row, ...metadata };
})();
modelMetadataBackfills.set(row.id, backfill);
try {
return await backfill;
} finally {
modelMetadataBackfills.delete(row.id);
}
}
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(modelPointcloudFiles)
const rows = await db.select().from(modelPointcloudFiles)
.where(eq(modelPointcloudFiles.modelId, modelId))
.orderBy(desc(modelPointcloudFiles.createdAt));
return Promise.all(rows.map(backfillModelPointcloudMetadata));
}
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
@@ -255,6 +290,36 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
return row;
}
export async function findModelPointcloudFileByModelAndName(
modelId: number,
name: string,
excludeId?: number,
): Promise<ModelPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const conditions = [
eq(modelPointcloudFiles.modelId, modelId),
sql`lower(${modelPointcloudFiles.name}) = lower(${name})`,
];
if (excludeId !== undefined) {
conditions.push(sql`${modelPointcloudFiles.id} <> ${excludeId}`);
}
const [row] = await db.select().from(modelPointcloudFiles).where(and(...conditions)).limit(1);
return row;
}
export async function updateModelPointcloudFileName(
id: number,
name: string,
): Promise<ModelPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(modelPointcloudFiles).set({ name }).where(eq(modelPointcloudFiles.id, id));
const row = await getModelPointcloudFileById(id);
if (!row) throw new Error("Model pointcloud file disappeared after update");
return row;
}
export async function deleteModelPointcloudFile(id: number): Promise<void> {
const db = await getDb();
if (!db) return;
@@ -448,6 +513,7 @@ export async function createRegisteredPointcloudFile(
): Promise<TaskPointcloudFile> {
const fs = await import('node:fs');
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
const metadata = inspectPointcloudMetadata(filePath, name);
return createTaskPointcloudFile({
taskId,
name,
@@ -456,15 +522,81 @@ export async function createRegisteredPointcloudFile(
source: 'registered',
sourceResultId,
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
...metadata,
} as InsertTaskPointcloudFile);
}
const taskMetadataBackfills = new Map<number, Promise<TaskPointcloudFile>>();
async function backfillTaskPointcloudMetadata(row: TaskPointcloudFile): Promise<TaskPointcloudFile> {
if (row.metadataStatus !== "pending") return row;
const existing = taskMetadataBackfills.get(row.id);
if (existing) return existing;
const backfill = (async () => {
const db = await getDb();
if (!db) return row;
const metadata = inspectPointcloudMetadata(row.filePath, row.name);
await db.update(taskPointcloudFiles)
.set(metadata)
.where(and(
eq(taskPointcloudFiles.id, row.id),
eq(taskPointcloudFiles.metadataStatus, "pending"),
));
return { ...row, ...metadata };
})();
taskMetadataBackfills.set(row.id, backfill);
try {
return await backfill;
} finally {
taskMetadataBackfills.delete(row.id);
}
}
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(taskPointcloudFiles)
const rows = await db.select().from(taskPointcloudFiles)
.where(eq(taskPointcloudFiles.taskId, taskId))
.orderBy(taskPointcloudFiles.createdAt);
return Promise.all(rows.map(backfillTaskPointcloudMetadata));
}
export async function getTaskPointcloudFileById(id: number): Promise<TaskPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const [row] = await db.select().from(taskPointcloudFiles).where(eq(taskPointcloudFiles.id, id));
return row;
}
export async function findTaskPointcloudFileByTaskAndName(
taskId: number,
name: string,
excludeId?: number,
): Promise<TaskPointcloudFile | undefined> {
const db = await getDb();
if (!db) return undefined;
const conditions = [
eq(taskPointcloudFiles.taskId, taskId),
sql`lower(${taskPointcloudFiles.name}) = lower(${name})`,
];
if (excludeId !== undefined) {
conditions.push(sql`${taskPointcloudFiles.id} <> ${excludeId}`);
}
const [row] = await db.select().from(taskPointcloudFiles).where(and(...conditions)).limit(1);
return row;
}
export async function updateTaskPointcloudFileName(
id: number,
name: string,
): Promise<TaskPointcloudFile> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(taskPointcloudFiles).set({ name }).where(eq(taskPointcloudFiles.id, id));
const row = await getTaskPointcloudFileById(id);
if (!row) throw new Error("Task pointcloud file disappeared after update");
return row;
}
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
@@ -507,6 +639,12 @@ export async function linkTaskPointcloudFilesFromModel(
filePath: source.filePath,
source: "model_library",
sourceModelPointcloudId: source.id,
metadataStatus: source.metadataStatus,
pointCount: source.pointCount,
pointAttributes: source.pointAttributes,
pointDataFormat: source.pointDataFormat,
bytesPerPoint: source.bytesPerPoint,
metadataError: source.metadataError,
} as InsertTaskPointcloudFile);
inserted.push(record);
existingSourceIds.add(id);
@@ -580,3 +718,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
if (!db) return;
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
}
/**
* 删除分析结果及其派生的已配准点云记录。
* 文件产物由调用方先清理;这里只负责在同一个事务中提交数据库变化。
*/
export async function deleteTaskAnalysisResultWithDerivedPointclouds(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.transaction(async (tx) => {
await tx.delete(taskPointcloudFiles).where(eq(taskPointcloudFiles.sourceResultId, id));
await tx.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = '请先修改初始密码';

View File

@@ -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<PointcloudMetadataStatus, "pending">;
pointCount: number | null;
pointAttributes: PointAttribute[] | null;
pointDataFormat: PointcloudDataFormat | null;
bytesPerPoint: number | null;
metadataError: string | null;
};

View File

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