feat: add local access control and managed point clouds
This commit is contained in:
@@ -34,11 +34,7 @@ COPY . .
|
|||||||
|
|
||||||
# 设置构建时环境变量
|
# 设置构建时环境变量
|
||||||
ARG VITE_APP_TITLE="宏量点云处理系统"
|
ARG VITE_APP_TITLE="宏量点云处理系统"
|
||||||
ARG VITE_OAUTH_PORTAL_URL="disabled"
|
|
||||||
ARG VITE_APP_ID=""
|
|
||||||
ENV VITE_APP_TITLE=$VITE_APP_TITLE
|
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
|
RUN pnpm build
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import { Route, Switch } from "wouter";
|
import { Redirect, Route, Switch } from "wouter";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import Home from "./pages/Home";
|
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() {
|
function Router() {
|
||||||
// make sure to consider if you need authentication for certain routes
|
// make sure to consider if you need authentication for certain routes
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<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} />
|
<Route path={"/404"} component={NotFound} />
|
||||||
{/* Final fallback route */}
|
{/* Final fallback route */}
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
|
|
||||||
const logoutMutation = trpc.auth.logout.useMutation({
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,21 +36,19 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
utils.auth.me.setData(undefined, null);
|
utils.auth.me.setData(undefined, undefined);
|
||||||
await utils.auth.me.invalidate();
|
await utils.auth.me.invalidate();
|
||||||
}
|
}
|
||||||
}, [logoutMutation, utils]);
|
}, [logoutMutation, utils]);
|
||||||
|
|
||||||
const state = useMemo(() => {
|
const state = useMemo(() => {
|
||||||
localStorage.setItem(
|
|
||||||
"manus-runtime-user-info",
|
|
||||||
JSON.stringify(meQuery.data)
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
user: meQuery.data ?? null,
|
user: meQuery.data ?? null,
|
||||||
loading: meQuery.isLoading || logoutMutation.isPending,
|
loading: meQuery.isLoading || logoutMutation.isPending,
|
||||||
error: meQuery.error ?? logoutMutation.error ?? null,
|
error: meQuery.error ?? logoutMutation.error ?? null,
|
||||||
isAuthenticated: Boolean(meQuery.data),
|
isAuthenticated: Boolean(meQuery.data),
|
||||||
|
canWrite: meQuery.data?.role === "admin" || meQuery.data?.role === "super_admin",
|
||||||
|
isSuperAdmin: meQuery.data?.role === "super_admin",
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
meQuery.data,
|
meQuery.data,
|
||||||
@@ -67,7 +65,7 @@ export function useAuth(options?: UseAuthOptions) {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname === redirectPath) return;
|
if (window.location.pathname === redirectPath) return;
|
||||||
|
|
||||||
window.location.href = redirectPath
|
window.location.href = redirectPath;
|
||||||
}, [
|
}, [
|
||||||
redirectOnUnauthenticated,
|
redirectOnUnauthenticated,
|
||||||
redirectPath,
|
redirectPath,
|
||||||
|
|||||||
@@ -1,23 +1,2 @@
|
|||||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
export { COOKIE_NAME, SESSION_TTL_MS } from "@shared/const";
|
||||||
|
export const getLoginUrl = () => "/login";
|
||||||
// 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();
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
import { httpBatchLink, httpLink, splitLink, TRPCClientError } from "@trpc/client";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
import { toast } from "sonner";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { getLoginUrl } from "./const";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
// 禁用 HMR 失败时的自动页面刷新,保留热更新功能
|
||||||
@@ -37,7 +37,7 @@ const redirectToLoginIfUnauthorized = (error: unknown) => {
|
|||||||
|
|
||||||
if (!isUnauthorized) return;
|
if (!isUnauthorized) return;
|
||||||
|
|
||||||
window.location.href = getLoginUrl();
|
if (window.location.pathname !== "/login") window.location.href = "/login";
|
||||||
};
|
};
|
||||||
|
|
||||||
queryClient.getQueryCache().subscribe(event => {
|
queryClient.getQueryCache().subscribe(event => {
|
||||||
@@ -52,6 +52,9 @@ queryClient.getMutationCache().subscribe(event => {
|
|||||||
if (event.type === "updated" && event.action.type === "error") {
|
if (event.type === "updated" && event.action.type === "error") {
|
||||||
const error = event.mutation.state.error;
|
const error = event.mutation.state.error;
|
||||||
redirectToLoginIfUnauthorized(error);
|
redirectToLoginIfUnauthorized(error);
|
||||||
|
if (error instanceof TRPCClientError && error.data?.code === "FORBIDDEN") {
|
||||||
|
toast.error(error.message || "无权执行此操作");
|
||||||
|
}
|
||||||
console.error("[API Mutation Error]", error);
|
console.error("[API Mutation Error]", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
48
client/src/pages/ChangePassword.tsx
Normal file
48
client/src/pages/ChangePassword.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
client/src/pages/Login.tsx
Normal file
53
client/src/pages/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/src/pages/Users.tsx
Normal file
68
client/src/pages/Users.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,16 +44,15 @@ services:
|
|||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请在 .env 配置至少 32 个字符的 JWT 密钥}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请在 .env 配置引导超级管理员用户名}
|
||||||
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请在 .env 配置至少 8 位的引导超级管理员密码}
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
|
||||||
- S3_BUCKET=pointcloud-data
|
- S3_BUCKET=pointcloud-data
|
||||||
- S3_REGION=us-east-1
|
- S3_REGION=us-east-1
|
||||||
- VITE_APP_TITLE=宏量点云处理系统
|
- VITE_APP_TITLE=宏量点云处理系统
|
||||||
- VITE_OAUTH_PORTAL_URL=disabled
|
|
||||||
- VITE_APP_ID=
|
|
||||||
# 数据目录路径
|
# 数据目录路径
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
# 算法源码根(通用插件 Route B 通过 {algRoot} 占位符引用)
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
- DATABASE_URL=mysql://pointcloud:pointcloud_password@localhost:3307/pointcloud_db
|
||||||
- JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
- JWT_SECRET=${JWT_SECRET:?请配置至少 32 个字符的 JWT 密钥}
|
||||||
# 内网部署禁用OAuth,使用本地认证
|
- BOOTSTRAP_ADMIN_USERNAME=${BOOTSTRAP_ADMIN_USERNAME:?请配置引导超级管理员用户名}
|
||||||
- AUTH_MODE=local
|
- BOOTSTRAP_ADMIN_PASSWORD=${BOOTSTRAP_ADMIN_PASSWORD:?请配置至少 8 位的引导超级管理员密码}
|
||||||
# MinIO S3兼容存储配置
|
# MinIO S3兼容存储配置
|
||||||
- S3_ENDPOINT=http://localhost:9010
|
- S3_ENDPOINT=http://localhost:9010
|
||||||
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
|||||||
@@ -16,15 +16,17 @@ USE pointcloud_db;
|
|||||||
-- 用户表
|
-- 用户表
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
openId VARCHAR(64) NOT NULL UNIQUE,
|
username VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
passwordHash VARCHAR(255),
|
||||||
name TEXT,
|
name TEXT,
|
||||||
email VARCHAR(320),
|
email VARCHAR(320),
|
||||||
loginMethod VARCHAR(64),
|
role ENUM('user', 'admin', 'super_admin') NOT NULL DEFAULT 'user',
|
||||||
role ENUM('user', '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,
|
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
lastLoginAt TIMESTAMP NULL,
|
||||||
INDEX idx_openId (openId),
|
|
||||||
INDEX idx_email (email(255))
|
INDEX idx_email (email(255))
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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` (
|
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
`modelId` INT NOT NULL,
|
`modelId` INT NOT NULL,
|
||||||
@@ -143,6 +161,16 @@ CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
|
|||||||
`name` VARCHAR(255) NOT NULL,
|
`name` VARCHAR(255) NOT NULL,
|
||||||
`fileSize` BIGINT NOT NULL,
|
`fileSize` BIGINT NOT NULL,
|
||||||
`filePath` VARCHAR(512) 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
|
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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
|
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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;
|
SELECT 'Database initialization completed successfully!' AS message;
|
||||||
|
|||||||
22
drizzle/0015_local_users_and_roles.sql
Normal file
22
drizzle/0015_local_users_and_roles.sql
Normal 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`;
|
||||||
15
drizzle/0016_pointcloud_metadata.sql
Normal file
15
drizzle/0016_pointcloud_metadata.sql
Normal 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;
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, bigint, json, uniqueIndex } from "drizzle-orm/mysql-core";
|
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", {
|
export const users = mysqlTable("users", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
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"),
|
name: text("name"),
|
||||||
email: varchar("email", { length: 320 }),
|
email: varchar("email", { length: 320 }),
|
||||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
role: mysqlEnum("role", ["user", "admin", "super_admin"]).default("user").notNull(),
|
||||||
role: mysqlEnum("role", ["user", "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(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
lastLoginAt: timestamp("lastLoginAt"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -71,6 +75,12 @@ export const modelPointcloudFiles = mysqlTable("model_pointcloud_files", {
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
fileSize: bigint("fileSize", { mode: "number" }).notNull(),
|
||||||
filePath: varchar("filePath", { length: 512 }).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(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,6 +146,12 @@ export const taskPointcloudFiles = mysqlTable("task_pointcloud_files", {
|
|||||||
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
sourcePointcloudFileId: int("sourcePointcloudFileId"),
|
||||||
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
/** 来源车型点云文件 ID(仅 model_library 时有值) */
|
||||||
sourceModelPointcloudId: int("sourceModelPointcloudId"),
|
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(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
153
server/_core/auth.ts
Normal file
153
server/_core/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
|
||||||
import type { User } from "../../drizzle/schema";
|
import type { User } from "../../drizzle/schema";
|
||||||
import { sdk } from "./sdk";
|
import { authenticateRequest } from "./auth";
|
||||||
|
|
||||||
export type TrpcContext = {
|
export type TrpcContext = {
|
||||||
req: CreateExpressContextOptions["req"];
|
req: CreateExpressContextOptions["req"];
|
||||||
@@ -14,7 +14,7 @@ export async function createContext(
|
|||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
user = await sdk.authenticateRequest(opts.req);
|
user = await authenticateRequest(opts.req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Authentication is optional for public procedures.
|
// Authentication is optional for public procedures.
|
||||||
user = null;
|
user = null;
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import type { CookieOptions, Request } from "express";
|
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) {
|
function isSecureRequest(req: Request) {
|
||||||
if (req.protocol === "https") return true;
|
if (req.protocol === "https") return true;
|
||||||
|
|
||||||
@@ -42,7 +34,7 @@ export function getSessionCookieOptions(
|
|||||||
return {
|
return {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "none",
|
sameSite: "lax",
|
||||||
secure: isSecureRequest(req),
|
secure: isSecureRequest(req),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
export const ENV = {
|
export const ENV = {
|
||||||
appId: process.env.VITE_APP_ID ?? "",
|
|
||||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
bootstrapAdminUsername: process.env.BOOTSTRAP_ADMIN_USERNAME ?? "",
|
||||||
authMode: process.env.AUTH_MODE ?? "oauth",
|
bootstrapAdminPassword: process.env.BOOTSTRAP_ADMIN_PASSWORD ?? "",
|
||||||
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 ?? "",
|
|
||||||
isProduction: process.env.NODE_ENV === "production",
|
isProduction: process.env.NODE_ENV === "production",
|
||||||
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
forgeApiUrl: process.env.BUILT_IN_FORGE_API_URL ?? "",
|
||||||
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
forgeApiKey: process.env.BUILT_IN_FORGE_API_KEY ?? "",
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import mysql from "mysql2/promise";
|
import mysql from "mysql2/promise";
|
||||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||||
import { registerOAuthRoutes } from "./oauth";
|
|
||||||
import { appRouter } from "../routers";
|
import { appRouter } from "../routers";
|
||||||
import { createContext } from "./context";
|
import { createContext } from "./context";
|
||||||
import { serveStatic, setupVite } from "./vite";
|
import { serveStatic, setupVite } from "./vite";
|
||||||
import { fileRouter } from "../fileRoutes";
|
import { fileRouter } from "../fileRoutes";
|
||||||
|
import { ensureBootstrapAdmin } from "./auth";
|
||||||
|
|
||||||
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -113,11 +113,8 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
try {
|
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
} catch (e) {
|
await ensureBootstrapAdmin();
|
||||||
console.error("[Migration] Unexpected error, server will still start:", e);
|
|
||||||
}
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
// Configure body parser with larger size limit for file uploads
|
// Configure body parser with larger size limit for file uploads
|
||||||
@@ -128,8 +125,6 @@ async function startServer() {
|
|||||||
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
server.timeout = 0; // 禁用 Node http.Server 全局超时
|
||||||
server.keepAliveTimeout = 0; // 长连接不超时
|
server.keepAliveTimeout = 0; // 长连接不超时
|
||||||
server.headersTimeout = 0; // 读请求头不超时
|
server.headersTimeout = 0; // 读请求头不超时
|
||||||
// OAuth callback under /api/oauth/callback
|
|
||||||
registerOAuthRoutes(app);
|
|
||||||
// 禁止代理(NPS 等)缓存 API 响应
|
// 禁止代理(NPS 等)缓存 API 响应
|
||||||
app.use('/api', (_req, res, next) => {
|
app.use('/api', (_req, res, next) => {
|
||||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||||
|
|||||||
@@ -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" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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 { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import type { TrpcContext } from "./context";
|
import type { TrpcContext } from "./context";
|
||||||
@@ -10,7 +10,7 @@ const t = initTRPC.context<TrpcContext>().create({
|
|||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const publicProcedure = t.procedure;
|
export const publicProcedure = t.procedure;
|
||||||
|
|
||||||
const requireUser = t.middleware(async opts => {
|
const requireSession = t.middleware(async opts => {
|
||||||
const { ctx, next } = opts;
|
const { ctx, next } = opts;
|
||||||
|
|
||||||
if (!ctx.user) {
|
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(
|
export const adminProcedure = t.procedure.use(
|
||||||
t.middleware(async opts => {
|
t.middleware(async opts => {
|
||||||
const { ctx, next } = 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 });
|
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 } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
57
server/analysisArtifacts.test.ts
Normal file
57
server/analysisArtifacts.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
68
server/analysisArtifacts.ts
Normal file
68
server/analysisArtifacts.ts
Normal 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 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
49
server/analysisResultDeletion.test.ts
Normal file
49
server/analysisResultDeletion.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
18
server/analysisResultDeletion.ts
Normal file
18
server/analysisResultDeletion.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -15,14 +15,17 @@ function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] }
|
|||||||
|
|
||||||
const user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
openId: "sample-user",
|
username: "sample-user",
|
||||||
|
passwordHash: null,
|
||||||
email: "sample@example.com",
|
email: "sample@example.com",
|
||||||
name: "Sample User",
|
name: "Sample User",
|
||||||
loginMethod: "manus",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
|
isActive: 1,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
lastSignedIn: new Date(),
|
lastLoginAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx: TrpcContext = {
|
const ctx: TrpcContext = {
|
||||||
@@ -54,7 +57,7 @@ describe("auth.logout", () => {
|
|||||||
expect(clearedCookies[0]?.options).toMatchObject({
|
expect(clearedCookies[0]?.options).toMatchObject({
|
||||||
maxAge: -1,
|
maxAge: -1,
|
||||||
secure: true,
|
secure: true,
|
||||||
sameSite: "none",
|
sameSite: "lax",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
});
|
});
|
||||||
|
|||||||
32
server/auth.security.test.ts
Normal file
32
server/auth.security.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
server/bootstrap-admin.test.ts
Normal file
37
server/bootstrap-admin.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
257
server/db.ts
257
server/db.ts
@@ -1,6 +1,7 @@
|
|||||||
import { eq, desc, inArray, sql, and } from "drizzle-orm";
|
import { eq, desc, inArray, sql, and, or, like } from "drizzle-orm";
|
||||||
import { drizzle } from "drizzle-orm/mysql2";
|
import { drizzle } from "drizzle-orm/mysql2";
|
||||||
import type { ResultSetHeader } from "mysql2";
|
import type { ResultSetHeader } from "mysql2";
|
||||||
|
import { inspectPointcloudMetadata } from "./pointcloudMetadata.js";
|
||||||
import {
|
import {
|
||||||
users, InsertUser,
|
users, InsertUser,
|
||||||
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
vehicleSeries, InsertVehicleSeries, VehicleSeries,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
taskPointcloudFiles, InsertTaskPointcloudFile, TaskPointcloudFile,
|
||||||
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
taskAnalysisResults, InsertTaskAnalysisResult, TaskAnalysisResult,
|
||||||
} from "../drizzle/schema";
|
} from "../drizzle/schema";
|
||||||
import { ENV } from './_core/env';
|
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
let _db: ReturnType<typeof drizzle> | null = null;
|
||||||
|
|
||||||
@@ -80,60 +80,67 @@ export async function getDb() {
|
|||||||
|
|
||||||
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
// ─── 用户 ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function upsertUser(user: InsertUser): Promise<void> {
|
export async function getUserById(id: number) {
|
||||||
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) {
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return undefined;
|
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];
|
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> {
|
export async function createVehicleSeries(data: InsertVehicleSeries): Promise<VehicleSeries> {
|
||||||
@@ -240,12 +247,40 @@ export async function createModelPointcloudFile(data: InsertModelPointcloudFile)
|
|||||||
return row;
|
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[]> {
|
export async function listModelPointcloudFiles(modelId: number): Promise<ModelPointcloudFile[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db.select().from(modelPointcloudFiles)
|
const rows = await db.select().from(modelPointcloudFiles)
|
||||||
.where(eq(modelPointcloudFiles.modelId, modelId))
|
.where(eq(modelPointcloudFiles.modelId, modelId))
|
||||||
.orderBy(desc(modelPointcloudFiles.createdAt));
|
.orderBy(desc(modelPointcloudFiles.createdAt));
|
||||||
|
return Promise.all(rows.map(backfillModelPointcloudMetadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
export async function getModelPointcloudFileById(id: number): Promise<ModelPointcloudFile | undefined> {
|
||||||
@@ -255,6 +290,36 @@ export async function getModelPointcloudFileById(id: number): Promise<ModelPoint
|
|||||||
return row;
|
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> {
|
export async function deleteModelPointcloudFile(id: number): Promise<void> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
@@ -448,6 +513,7 @@ export async function createRegisteredPointcloudFile(
|
|||||||
): Promise<TaskPointcloudFile> {
|
): Promise<TaskPointcloudFile> {
|
||||||
const fs = await import('node:fs');
|
const fs = await import('node:fs');
|
||||||
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
|
||||||
|
const metadata = inspectPointcloudMetadata(filePath, name);
|
||||||
return createTaskPointcloudFile({
|
return createTaskPointcloudFile({
|
||||||
taskId,
|
taskId,
|
||||||
name,
|
name,
|
||||||
@@ -456,15 +522,81 @@ export async function createRegisteredPointcloudFile(
|
|||||||
source: 'registered',
|
source: 'registered',
|
||||||
sourceResultId,
|
sourceResultId,
|
||||||
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
sourcePointcloudFileId: sourcePointcloudFileId ?? null,
|
||||||
|
...metadata,
|
||||||
} as InsertTaskPointcloudFile);
|
} 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[]> {
|
export async function listTaskPointcloudFiles(taskId: number): Promise<TaskPointcloudFile[]> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db.select().from(taskPointcloudFiles)
|
const rows = await db.select().from(taskPointcloudFiles)
|
||||||
.where(eq(taskPointcloudFiles.taskId, taskId))
|
.where(eq(taskPointcloudFiles.taskId, taskId))
|
||||||
.orderBy(taskPointcloudFiles.createdAt);
|
.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> {
|
export async function deleteTaskPointcloudFile(id: number): Promise<void> {
|
||||||
@@ -507,6 +639,12 @@ export async function linkTaskPointcloudFilesFromModel(
|
|||||||
filePath: source.filePath,
|
filePath: source.filePath,
|
||||||
source: "model_library",
|
source: "model_library",
|
||||||
sourceModelPointcloudId: source.id,
|
sourceModelPointcloudId: source.id,
|
||||||
|
metadataStatus: source.metadataStatus,
|
||||||
|
pointCount: source.pointCount,
|
||||||
|
pointAttributes: source.pointAttributes,
|
||||||
|
pointDataFormat: source.pointDataFormat,
|
||||||
|
bytesPerPoint: source.bytesPerPoint,
|
||||||
|
metadataError: source.metadataError,
|
||||||
} as InsertTaskPointcloudFile);
|
} as InsertTaskPointcloudFile);
|
||||||
inserted.push(record);
|
inserted.push(record);
|
||||||
existingSourceIds.add(id);
|
existingSourceIds.add(id);
|
||||||
@@ -580,3 +718,16 @@ export async function deleteTaskAnalysisResult(id: number): Promise<void> {
|
|||||||
if (!db) return;
|
if (!db) return;
|
||||||
await db.delete(taskAnalysisResults).where(eq(taskAnalysisResults.id, id));
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type RequestHandler } from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
|
import { samplePointcloudFile, type SampledPoint } from './pointcloudSample.js';
|
||||||
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
import { forceReload as reloadPluginRegistry } from './plugins/registry.js';
|
||||||
|
import { authenticateRequest } from './_core/auth.js';
|
||||||
|
import { inspectPointcloudMetadata } from './pointcloudMetadata.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -48,6 +50,33 @@ const diskUpload = multer({
|
|||||||
limits: { fileSize: 50 * 1024 * 1024 * 1024 }, // 50 GB
|
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 {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -66,7 +95,7 @@ function safeResolve(base: string, filename: string): string | null {
|
|||||||
// POST /api/files/upload/cad/:modelId
|
// POST /api/files/upload/cad/:modelId
|
||||||
// multipart/form-data field: file
|
// 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);
|
const modelId = parseInt(req.params.modelId, 10);
|
||||||
if (!req.file || isNaN(modelId)) {
|
if (!req.file || isNaN(modelId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or 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
|
// POST /api/files/upload/model-pointcloud/:modelId
|
||||||
// multipart/form-data field: file
|
// 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);
|
const modelId = parseInt(req.params.modelId, 10);
|
||||||
if (!req.file || isNaN(modelId)) {
|
if (!req.file || isNaN(modelId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
return res.status(400).json({ success: false, error: 'Missing file or modelId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const destPath = req.file.path;
|
const destPath = req.file.path;
|
||||||
|
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||||
const record = await db.createModelPointcloudFile({
|
const record = await db.createModelPointcloudFile({
|
||||||
modelId,
|
modelId,
|
||||||
name: req.file.originalname,
|
name: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -126,7 +157,7 @@ router.post('/upload/model-pointcloud/:modelId', diskUpload.single('file'), asyn
|
|||||||
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
// ─── 任务 CAD 文件上传 ────────────────────────────────────────────────────────
|
||||||
// POST /api/files/upload/task-cad/:taskId
|
// 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);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
if (!req.file || isNaN(taskId)) {
|
if (!req.file || isNaN(taskId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or 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 文件 ────────────────────────────────────────────────────────
|
// ─── 读取任务 CAD 文件 ────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/task-cad/:taskId/:filename
|
// 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 cadDir = path.join(TASKS_DIR, req.params.taskId, 'cad');
|
||||||
const filePath = safeResolve(cadDir, req.params.filename);
|
const filePath = safeResolve(cadDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
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
|
// 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);
|
const taskId = parseInt(req.params.taskId, 10);
|
||||||
if (!req.file || isNaN(taskId)) {
|
if (!req.file || isNaN(taskId)) {
|
||||||
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
return res.status(400).json({ success: false, error: 'Missing file or taskId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const destPath = req.file.path;
|
const destPath = req.file.path;
|
||||||
|
const metadata = inspectPointcloudMetadata(destPath, req.file.originalname);
|
||||||
const record = await db.createTaskPointcloudFile({
|
const record = await db.createTaskPointcloudFile({
|
||||||
taskId,
|
taskId,
|
||||||
name: req.file.originalname,
|
name: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
filePath: destPath,
|
filePath: destPath,
|
||||||
|
...metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -196,7 +229,7 @@ router.post('/upload/pointcloud/:taskId', diskUpload.single('file'), async (req,
|
|||||||
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 读取 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// GET /api/files/read/cad/:modelId/:filename
|
// 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 cadDir = path.join(MODELS_DIR, req.params.modelId, 'cad');
|
||||||
const filePath = safeResolve(cadDir, req.params.filename);
|
const filePath = safeResolve(cadDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
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
|
// 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 pcDir = path.join(MODELS_DIR, req.params.modelId, 'pointcloud');
|
||||||
const filePath = safeResolve(pcDir, req.params.filename);
|
const filePath = safeResolve(pcDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
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
|
// 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 pcDir = path.join(TASKS_DIR, req.params.taskId, 'pointcloud');
|
||||||
const filePath = safeResolve(pcDir, req.params.filename);
|
const filePath = safeResolve(pcDir, req.params.filename);
|
||||||
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
if (!filePath) return res.status(403).json({ error: 'Access denied' });
|
||||||
@@ -229,7 +262,7 @@ router.get('/read/pointcloud/:taskId/:filename', (req, res) => {
|
|||||||
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
// ─── 删除 CAD 文件 ─────────────────────────────────────────────────────────────
|
||||||
// DELETE /api/files/delete/cad/:id
|
// 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);
|
const id = parseInt(req.params.id, 10);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
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
|
// 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);
|
const id = parseInt(req.params.id, 10);
|
||||||
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Invalid id' });
|
||||||
await db.deleteTaskPointcloudFile(id);
|
await db.deleteTaskPointcloudFile(id);
|
||||||
@@ -254,13 +287,11 @@ router.delete('/delete/pointcloud/:id', async (req, res) => {
|
|||||||
|
|
||||||
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
// ─── 批量采样接口 ─────────────────────────────────────────────────────────────
|
||||||
// POST /api/files/sample/batch
|
// POST /api/files/sample/batch
|
||||||
// Body: { taskId: number, totalPoints?: number }
|
// Body: { taskId: number, fileIds?: number[], totalPoints?: number }
|
||||||
// or { filePaths: string[], totalPoints?: number } (内部绝对路径,需认证)
|
|
||||||
|
|
||||||
router.post('/sample/batch', async (req, res) => {
|
router.post('/sample/batch', requireFileRead, async (req, res) => {
|
||||||
const { taskId, filePaths: rawPaths, totalPoints = 1000000, fileIds } = req.body as {
|
const { taskId, totalPoints = 1000000, fileIds } = req.body as {
|
||||||
taskId?: number;
|
taskId?: number;
|
||||||
filePaths?: string[];
|
|
||||||
totalPoints?: number;
|
totalPoints?: number;
|
||||||
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
/** 可选:只采样指定 ID 的文件(按 task_pointcloud_files.id);空/缺省 = 全部 */
|
||||||
fileIds?: number[];
|
fileIds?: number[];
|
||||||
@@ -268,7 +299,7 @@ router.post('/sample/batch', async (req, res) => {
|
|||||||
|
|
||||||
let paths: string[] = [];
|
let paths: string[] = [];
|
||||||
|
|
||||||
if (taskId != null) {
|
if (taskId != null && Number.isInteger(taskId) && taskId > 0) {
|
||||||
let records = await db.listTaskPointcloudFiles(taskId);
|
let records = await db.listTaskPointcloudFiles(taskId);
|
||||||
// 当前端传入 fileIds 时只采样选中的文件
|
// 当前端传入 fileIds 时只采样选中的文件
|
||||||
if (Array.isArray(fileIds) && fileIds.length > 0) {
|
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));
|
records = records.filter((r) => idSet.has(r.id));
|
||||||
}
|
}
|
||||||
paths = records.map((r) => r.filePath);
|
paths = records.map((r) => r.filePath);
|
||||||
} else if (Array.isArray(rawPaths) && rawPaths.length > 0) {
|
|
||||||
paths = rawPaths;
|
|
||||||
} else {
|
} else {
|
||||||
return res.status(400).json({ error: 'Provide taskId or filePaths' });
|
return res.status(400).json({ error: '请提供有效的 taskId' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (paths.length === 0) {
|
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);
|
const totalSampled = sampledPerFile.reduce((s, pts) => s + pts.length, 0);
|
||||||
// 如果任意点有颜色,则输出 28 字节/点(x,y,z,error,r,g,b 各 float32);否则 16 字节
|
// 如果任意点有颜色,则输出 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 hasColor = sampledPerFile.some((pts) => pts.some((p) => p.r !== undefined));
|
||||||
const bytesPerPoint = hasColor ? 28 : 16;
|
const bytesPerPoint = hasColor ? 28 : 16;
|
||||||
const binBuf = Buffer.allocUnsafe(totalSampled * bytesPerPoint);
|
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.x, offset);
|
||||||
binBuf.writeFloatLE(p.y, offset + 4);
|
binBuf.writeFloatLE(p.y, offset + 4);
|
||||||
binBuf.writeFloatLE(p.z, offset + 8);
|
binBuf.writeFloatLE(p.z, offset + 8);
|
||||||
binBuf.writeFloatLE(p.error, offset + 12);
|
binBuf.writeFloatLE(p.error ?? Number.NaN, offset + 12);
|
||||||
if (hasColor) {
|
if (hasColor) {
|
||||||
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
|
binBuf.writeFloatLE((p.r ?? 128) / 255, offset + 16);
|
||||||
binBuf.writeFloatLE((p.g ?? 128) / 255, offset + 20);
|
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('Content-Type', 'application/octet-stream');
|
||||||
res.setHeader('X-Point-Count', totalSampled);
|
res.setHeader('X-Point-Count', totalSampled);
|
||||||
res.setHeader('X-Has-Color', hasColor ? '1' : '0');
|
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.setHeader('X-File-Stats', JSON.stringify(fileStats));
|
||||||
res.send(binBuf);
|
res.send(binBuf);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -366,7 +397,7 @@ router.post('/sample/batch', async (req, res) => {
|
|||||||
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
|
// ─── 平滑热力分析结果(异步任务产物,仅读取已落盘的顶点色)──────────────────────
|
||||||
// GET /api/files/read/analysis/heatmap/:taskId/:resultId
|
// 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 taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
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
|
// 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 taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
const filename = req.params.filename;
|
const filename = req.params.filename;
|
||||||
@@ -432,7 +463,7 @@ router.get('/read/analysis/assembly-vertex/:taskId/:resultId/:filename', async (
|
|||||||
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
// 注意:此路由必须放在 heatmap / assembly-vertex 等具体路由之后,否则
|
||||||
// 会用 taskId="heatmap" 这种字符串把请求误吞掉。
|
// 会用 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 taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
const filename = (req.params as Record<string, string>)[0] || (req.params as Record<string, string>)['0'];
|
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
|
// POST /api/files/resync/analysis/:taskId/:resultId
|
||||||
// 把磁盘上的 result.json 重新同步到数据库,用于手动修复 overlay3d 等字段。
|
// 把磁盘上的 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 taskId = parseInt(req.params.taskId, 10);
|
||||||
const resultId = parseInt(req.params.resultId, 10);
|
const resultId = parseInt(req.params.resultId, 10);
|
||||||
if (Number.isNaN(taskId) || Number.isNaN(resultId)) {
|
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
|
// POST /api/files/plugins/reload
|
||||||
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
// 重新从磁盘扫描 manifests/*.json,无需重启服务器。
|
||||||
// 用于更新算法版本后立即生效。
|
// 用于更新算法版本后立即生效。
|
||||||
router.post('/plugins/reload', (_req, res) => {
|
router.post('/plugins/reload', requireFileWrite, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const plugins = reloadPluginRegistry();
|
const plugins = reloadPluginRegistry();
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
|||||||
function createAuthContext(): TrpcContext {
|
function createAuthContext(): TrpcContext {
|
||||||
const user: AuthenticatedUser = {
|
const user: AuthenticatedUser = {
|
||||||
id: 1,
|
id: 1,
|
||||||
openId: "test-user",
|
username: "test-user",
|
||||||
|
passwordHash: null,
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
loginMethod: "manus",
|
role: "admin",
|
||||||
role: "user",
|
isActive: 1,
|
||||||
|
mustChangePassword: 0,
|
||||||
|
sessionVersion: 0,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
lastSignedIn: new Date(),
|
lastLoginAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
63
server/permissions.test.ts
Normal file
63
server/permissions.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
125
server/pointcloudMetadata.test.ts
Normal file
125
server/pointcloudMetadata.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
153
server/pointcloudMetadata.ts
Normal file
153
server/pointcloudMetadata.ts
Normal 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 文件头解析失败",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
181
server/pointcloudRename.test.ts
Normal file
181
server/pointcloudRename.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
server/pointcloudSample.test.ts
Normal file
60
server/pointcloudSample.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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 文本点云行 */
|
/** 与 fileRoutes 批量采样一致:随机 seek 文本点云行 */
|
||||||
export async function randomSeekSample(
|
export async function randomSeekSample(
|
||||||
@@ -66,9 +66,10 @@ export async function randomSeekSample(
|
|||||||
const y = parseFloat(parts[1]);
|
const y = parseFloat(parts[1]);
|
||||||
const z = parseFloat(parts[2]);
|
const z = parseFloat(parts[2]);
|
||||||
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
if (isNaN(x) || isNaN(y) || isNaN(z)) continue;
|
||||||
let error = parts.length >= 4 ? parseFloat(parts[3]) : NaN;
|
const parsedError = 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;
|
const point: SampledPoint = { x, y, z };
|
||||||
points.push({ x, y, z, error });
|
if (Number.isFinite(parsedError)) point.error = parsedError;
|
||||||
|
points.push(point);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
fs.closeSync(fd);
|
fs.closeSync(fd);
|
||||||
@@ -173,8 +174,10 @@ function samplePlyBinary(
|
|||||||
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
|
// 找 x, y, z,以及 red/green/blue(uchar 0-255)
|
||||||
let xOff = -1, yOff = -1, zOff = -1;
|
let xOff = -1, yOff = -1, zOff = -1;
|
||||||
let rOff = -1, gOff = -1, bOff = -1;
|
let rOff = -1, gOff = -1, bOff = -1;
|
||||||
|
let errorOff = -1;
|
||||||
let xType = 'float', yType = 'float', zType = 'float';
|
let xType = 'float', yType = 'float', zType = 'float';
|
||||||
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
|
let rType = 'uchar', gType = 'uchar', bType = 'uchar';
|
||||||
|
let errorType = 'float';
|
||||||
let byteOff = 0;
|
let byteOff = 0;
|
||||||
for (const p of properties) {
|
for (const p of properties) {
|
||||||
const n = p.name.toLowerCase();
|
const n = p.name.toLowerCase();
|
||||||
@@ -184,6 +187,7 @@ function samplePlyBinary(
|
|||||||
else if (n === 'red') { rOff = byteOff; rType = p.type; }
|
else if (n === 'red') { rOff = byteOff; rType = p.type; }
|
||||||
else if (n === 'green') { gOff = byteOff; gType = p.type; }
|
else if (n === 'green') { gOff = byteOff; gType = p.type; }
|
||||||
else if (n === 'blue') { bOff = byteOff; bType = 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;
|
byteOff += p.byteSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +232,11 @@ function samplePlyBinary(
|
|||||||
const y = readNum(recBuf, yOff, yType);
|
const y = readNum(recBuf, yOff, yType);
|
||||||
const z = readNum(recBuf, zOff, zType);
|
const z = readNum(recBuf, zOff, zType);
|
||||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
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 };
|
||||||
const pt: SampledPoint = { x, y, z, error };
|
if (errorOff >= 0) {
|
||||||
|
const error = readNum(recBuf, errorOff, errorType);
|
||||||
|
if (Number.isFinite(error)) pt.error = error;
|
||||||
|
}
|
||||||
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
|
if (rOff >= 0 && gOff >= 0 && bOff >= 0) {
|
||||||
pt.r = readNum(recBuf, rOff, rType);
|
pt.r = readNum(recBuf, rOff, rType);
|
||||||
pt.g = readNum(recBuf, gOff, gType);
|
pt.g = readNum(recBuf, gOff, gType);
|
||||||
@@ -289,18 +296,22 @@ function samplePlyAscii(
|
|||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const parts = line.split(/\s+/);
|
const parts = line.split(/\s+/);
|
||||||
// 按 header properties 顺序找 x/y/z/red/green/blue 的列索引
|
// 按 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) => {
|
header.properties.forEach((p, i) => {
|
||||||
const n = p.name.toLowerCase();
|
const n = p.name.toLowerCase();
|
||||||
if (n === 'x') xi = i; else if (n === 'y') yi = i; else if (n === 'z') zi = i;
|
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 === '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 x = parseFloat(parts[xi >= 0 ? xi : 0]);
|
||||||
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
|
const y = parseFloat(parts[yi >= 0 ? yi : 1]);
|
||||||
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
|
const z = parseFloat(parts[zi >= 0 ? zi : 2]);
|
||||||
if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
|
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 };
|
||||||
const pt: SampledPoint = { x, y, z, error };
|
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) {
|
if (ri >= 0 && gi >= 0 && bi >= 0 && parts[ri] !== undefined) {
|
||||||
pt.r = parseFloat(parts[ri]);
|
pt.r = parseFloat(parts[ri]);
|
||||||
pt.g = parseFloat(parts[gi]);
|
pt.g = parseFloat(parts[gi]);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 { getSessionCookieOptions } from "./_core/cookies";
|
||||||
import { systemRouter } from "./_core/systemRouter";
|
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 { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
@@ -15,10 +15,37 @@ import { stepParseCacheDir } from "./stepParse.js";
|
|||||||
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
import { listPlugins, getPlugin } from "./plugins/registry.js";
|
||||||
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
import { enqueueAlgorithmJob, algorithmJobStats, cancelAlgorithmJob, cancelAlgorithmJobsByTask } from "./algorithmJob.js";
|
||||||
import { cancelLegacyAnalysisJob, cancelLegacyAnalysisJobsByTask } from "./analysisJobControl.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 DATA_DIR = process.env.DATA_DIR || "/data";
|
||||||
const TASKS_DIR = path.join(DATA_DIR, "tasks");
|
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 = [
|
const DEMO_MEASUREMENT_PLUGIN_IDS = [
|
||||||
"train-measurement",
|
"train-measurement",
|
||||||
"coupler-panel",
|
"coupler-panel",
|
||||||
@@ -306,12 +333,164 @@ export const appRouter = router({
|
|||||||
system: systemRouter,
|
system: systemRouter,
|
||||||
|
|
||||||
auth: router({
|
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 }) => {
|
logout: publicProcedure.mutation(({ ctx }) => {
|
||||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||||
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
||||||
return { success: true } as const;
|
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);
|
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
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -596,6 +792,23 @@ export const appRouter = router({
|
|||||||
return db.listTaskPointcloudFiles(input.taskId);
|
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
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -801,11 +1014,7 @@ export const appRouter = router({
|
|||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
.input(z.object({ id: z.number() }))
|
.input(z.object({ id: z.number() }))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
// 先取消正在运行或排队的 job,再删数据库记录
|
return deleteAnalysisResult(input.id);
|
||||||
cancelAlgorithmJob(input.id);
|
|
||||||
cancelLegacyAnalysisJob(input.id);
|
|
||||||
await db.deleteTaskAnalysisResult(input.id);
|
|
||||||
return { success: true };
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
44
server/session.security.test.ts
Normal file
44
server/session.security.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
61
server/users.security.test.ts
Normal file
61
server/users.security.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const COOKIE_NAME = "app_session_id";
|
export const COOKIE_NAME = "app_session_id";
|
||||||
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
|
export const SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
||||||
export const AXIOS_TIMEOUT_MS = 30_000;
|
export const UNAUTHED_ERR_MSG = '请先登录';
|
||||||
export const UNAUTHED_ERR_MSG = 'Please login (10001)';
|
export const NOT_ADMIN_ERR_MSG = '无权执行此操作';
|
||||||
export const NOT_ADMIN_ERR_MSG = 'You do not have required permission (10002)';
|
export const MUST_CHANGE_PASSWORD_ERR_MSG = '请先修改初始密码';
|
||||||
|
|||||||
21
shared/pointcloudMetadata.ts
Normal file
21
shared/pointcloudMetadata.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -14,6 +14,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["server/**/*.test.ts", "server/**/*.spec.ts"],
|
include: ["server/**/*.test.ts", "server/**/*.spec.ts", "client/src/**/*.test.ts", "client/src/**/*.spec.ts"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user