Fix recorder finalize path and add invite-gated login
这个提交包含在:
@@ -3,6 +3,7 @@ PORT=3000
|
|||||||
# App auth / storage / database
|
# App auth / storage / database
|
||||||
DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub
|
DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub
|
||||||
JWT_SECRET=replace-with-strong-secret
|
JWT_SECRET=replace-with-strong-secret
|
||||||
|
REGISTRATION_INVITE_CODE=CA2026
|
||||||
VITE_APP_ID=tennis-training-hub
|
VITE_APP_ID=tennis-training-hub
|
||||||
OAUTH_SERVER_URL=
|
OAUTH_SERVER_URL=
|
||||||
OWNER_OPEN_ID=
|
OWNER_OPEN_ID=
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
- 摄像头中断后自动重连,保留既有分段与会话
|
- 摄像头中断后自动重连,保留既有分段与会话
|
||||||
- Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
|
- Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4
|
||||||
- Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果
|
- Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果
|
||||||
|
- 服务端媒体会话校验兼容 `/media/sessions/...` 路径,避免录制结束时因路径不一致导致 404
|
||||||
|
|
||||||
## Background Tasks
|
## Background Tasks
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@
|
|||||||
|
|
||||||
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
|
前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。
|
||||||
|
|
||||||
|
另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录。
|
||||||
|
|
||||||
## Multimodal LLM
|
## Multimodal LLM
|
||||||
|
|
||||||
- 文本类任务使用 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL`
|
- 文本类任务使用 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL`
|
||||||
@@ -42,6 +45,7 @@
|
|||||||
- 若视觉模型链路不可用,系统会自动回退到结构化指标驱动的文本纠正,避免任务直接失败
|
- 若视觉模型链路不可用,系统会自动回退到结构化指标驱动的文本纠正,避免任务直接失败
|
||||||
- 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果
|
- 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果
|
||||||
- `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1`
|
- `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1`
|
||||||
|
- 用户名登录支持直接进入系统;仅首次创建新用户时需要填写 `REGISTRATION_INVITE_CODE`
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -117,6 +121,7 @@ pnpm exec playwright install chromium
|
|||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `JWT_SECRET`
|
- `JWT_SECRET`
|
||||||
- `ADMIN_USERNAMES`
|
- `ADMIN_USERNAMES`
|
||||||
|
- `REGISTRATION_INVITE_CODE`
|
||||||
- `MYSQL_DATABASE`
|
- `MYSQL_DATABASE`
|
||||||
- `MYSQL_USER`
|
- `MYSQL_USER`
|
||||||
- `MYSQL_PASSWORD`
|
- `MYSQL_PASSWORD`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Recorder from "./pages/Recorder";
|
|||||||
import Tutorials from "./pages/Tutorials";
|
import Tutorials from "./pages/Tutorials";
|
||||||
import Reminders from "./pages/Reminders";
|
import Reminders from "./pages/Reminders";
|
||||||
import VisionLab from "./pages/VisionLab";
|
import VisionLab from "./pages/VisionLab";
|
||||||
|
import Logs from "./pages/Logs";
|
||||||
|
|
||||||
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
function DashboardRoute({ component: Component }: { component: React.ComponentType }) {
|
||||||
return (
|
return (
|
||||||
@@ -70,6 +71,9 @@ function Router() {
|
|||||||
<Route path="/reminders">
|
<Route path="/reminders">
|
||||||
<DashboardRoute component={Reminders} />
|
<DashboardRoute component={Reminders} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/logs">
|
||||||
|
<DashboardRoute component={Logs} />
|
||||||
|
</Route>
|
||||||
<Route path="/vision-lab">
|
<Route path="/vision-lab">
|
||||||
<DashboardRoute component={VisionLab} />
|
<DashboardRoute component={VisionLab} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile";
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
LayoutDashboard, LogOut, PanelLeft, Target, Video,
|
||||||
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot,
|
||||||
BookOpen, Bell, Microscope
|
BookOpen, Bell, Microscope, ScrollText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation, Redirect } from "wouter";
|
import { useLocation, Redirect } from "wouter";
|
||||||
@@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
{ icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" },
|
||||||
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
{ icon: BookOpen, label: "教程库", path: "/tutorials", group: "learn" },
|
||||||
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
{ icon: Bell, label: "训练提醒", path: "/reminders", group: "learn" },
|
||||||
|
{ icon: ScrollText, label: "系统日志", path: "/logs", group: "learn" },
|
||||||
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
{ icon: Microscope, label: "视觉测试", path: "/vision-lab", group: "learn", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Target, Loader2 } from "lucide-react";
|
|||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const loginMutation = trpc.auth.loginWithUsername.useMutation();
|
const loginMutation = trpc.auth.loginWithUsername.useMutation();
|
||||||
@@ -37,7 +38,10 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loginMutation.mutateAsync({ username: username.trim() });
|
const data = await loginMutation.mutateAsync({
|
||||||
|
username: username.trim(),
|
||||||
|
inviteCode: inviteCode.trim() || undefined,
|
||||||
|
});
|
||||||
const user = await syncAuthenticatedUser(data.user);
|
const user = await syncAuthenticatedUser(data.user);
|
||||||
toast.success(data.isNew ? `已创建用户:${user.name}` : `已登录:${user.name}`);
|
toast.success(data.isNew ? `已创建用户:${user.name}` : `已登录:${user.name}`);
|
||||||
setLocation("/dashboard");
|
setLocation("/dashboard");
|
||||||
@@ -77,6 +81,20 @@ export default function Login() {
|
|||||||
maxLength={64}
|
maxLength={64}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
data-testid="login-invite-code-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="邀请码,仅新用户首次登录需要"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
className="h-12 text-base"
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
已存在账号只需输入用户名。新用户首次登录需要邀请码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
data-testid="login-submit-button"
|
data-testid="login-submit-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -114,7 +132,7 @@ export default function Login() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground mt-6">
|
<p className="text-center text-xs text-muted-foreground mt-6">
|
||||||
输入用户名后进入系统
|
直接输入用户名登录;新用户首次登录需填写邀请码
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
228
client/src/pages/Logs.tsx
普通文件
228
client/src/pages/Logs.tsx
普通文件
@@ -0,0 +1,228 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AlertTriangle, BellRing, CheckCircle2, ClipboardList, Loader2, RefreshCcw } from "lucide-react";
|
||||||
|
|
||||||
|
function formatTaskStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return "执行中";
|
||||||
|
case "succeeded":
|
||||||
|
return "已完成";
|
||||||
|
case "failed":
|
||||||
|
return "失败";
|
||||||
|
default:
|
||||||
|
return "排队中";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNotificationState(isRead: number | boolean | null | undefined) {
|
||||||
|
return isRead ? "已读" : "未读";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStructuredValue(value: unknown) {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const taskListQuery = trpc.task.list.useQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const hasActiveTask = (query.state.data ?? []).some((task) => task.status === "queued" || task.status === "running");
|
||||||
|
return hasActiveTask ? 3_000 : 10_000;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const notificationQuery = trpc.notification.list.useQuery({ limit: 50 });
|
||||||
|
const retryMutation = trpc.task.retry.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.task.list.invalidate();
|
||||||
|
toast.success("任务已重新排队");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(`任务重试失败: ${error.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeTaskCount = useMemo(
|
||||||
|
() => (taskListQuery.data ?? []).filter((task) => task.status === "queued" || task.status === "running").length,
|
||||||
|
[taskListQuery.data],
|
||||||
|
);
|
||||||
|
const failedTaskCount = useMemo(
|
||||||
|
() => (taskListQuery.data ?? []).filter((task) => task.status === "failed").length,
|
||||||
|
[taskListQuery.data],
|
||||||
|
);
|
||||||
|
const unreadNotificationCount = useMemo(
|
||||||
|
() => (notificationQuery.data ?? []).filter((item) => !item.isRead).length,
|
||||||
|
[notificationQuery.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taskListQuery.isLoading && notificationQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-28 w-full" />
|
||||||
|
<Skeleton className="h-96 w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">系统日志</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
查看后台任务、归档失败原因和通知记录。录制结束失败、训练计划生成失败等信息会保留在这里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary">活动任务 {activeTaskCount}</Badge>
|
||||||
|
<Badge variant={failedTaskCount > 0 ? "destructive" : "secondary"}>失败任务 {failedTaskCount}</Badge>
|
||||||
|
<Badge variant="outline">未读通知 {unreadNotificationCount}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
<AlertTitle>排障入口</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
如果录制归档、视频分析或训练计划生成失败,先看“后台任务”里的错误信息,再根据任务标题定位具体模块。
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Tabs defaultValue="tasks" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="tasks">后台任务</TabsTrigger>
|
||||||
|
<TabsTrigger value="notifications">通知记录</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="tasks">
|
||||||
|
<ScrollArea className="max-h-[70vh] pr-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(taskListQuery.data ?? []).length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||||
|
还没有后台任务记录。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
(taskListQuery.data ?? []).map((task) => (
|
||||||
|
<Card key={task.id} className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base">{task.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{new Date(task.createdAt).toLocaleString("zh-CN")} · {task.type}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={task.status === "failed" ? "destructive" : "secondary"}>
|
||||||
|
{formatTaskStatus(task.status)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{task.message || formatTaskStatus(task.status)}</p>
|
||||||
|
|
||||||
|
{task.error ? (
|
||||||
|
<div className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span className="whitespace-pre-wrap break-words">{task.error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{task.result ? (
|
||||||
|
<pre className="overflow-x-auto rounded-xl bg-muted/60 p-3 text-xs leading-5 whitespace-pre-wrap break-words">
|
||||||
|
{formatStructuredValue(task.result)}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>进度 {task.progress}% · 尝试 {task.attempts}/{task.maxAttempts}</span>
|
||||||
|
{task.status === "failed" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => retryMutation.mutate({ taskId: task.id })}
|
||||||
|
disabled={retryMutation.isPending}
|
||||||
|
>
|
||||||
|
{retryMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : task.status === "succeeded" ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-emerald-600">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
已完成
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-primary">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
处理中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="notifications">
|
||||||
|
<ScrollArea className="max-h-[70vh] pr-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(notificationQuery.data ?? []).length === 0 ? (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||||
|
还没有通知记录。
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
(notificationQuery.data ?? []).map((item) => (
|
||||||
|
<Card key={item.id} className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-base">{item.title}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={item.isRead ? "secondary" : "outline"}>
|
||||||
|
{formatNotificationState(item.isRead)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start gap-2 text-sm">
|
||||||
|
<BellRing className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<p className="whitespace-pre-wrap break-words">{item.message || "无附加内容"}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
# Verified Features
|
# Verified Features
|
||||||
|
|
||||||
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:40 CST。
|
本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:52 CST。
|
||||||
|
|
||||||
## 最新完整验证记录
|
## 最新完整验证记录
|
||||||
|
|
||||||
- 通过命令:`pnpm verify`
|
- 通过命令:`pnpm verify`
|
||||||
- 验证时间:2026-03-15 00:39 CST
|
- 验证时间:2026-03-15 00:51 CST
|
||||||
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(85/85),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过
|
- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(90/90),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过
|
||||||
|
|
||||||
## 生产部署联测
|
## 生产部署联测
|
||||||
|
|
||||||
| 项目 | 验证方式 | 状态 |
|
| 项目 | 验证方式 | 状态 |
|
||||||
|------|----------|------|
|
|------|----------|------|
|
||||||
| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 |
|
| `https://te.hao.work/` HTTPS 访问 | `curl -I https://te.hao.work/` | 通过 |
|
||||||
|
| `https://te.hao.work/logs` 日志页访问 | `curl -I https://te.hao.work/logs` | 通过 |
|
||||||
| `https://te.hao.work/vision-lab` 视觉测试页访问 | `curl -I https://te.hao.work/vision-lab` | 通过 |
|
| `https://te.hao.work/vision-lab` 视觉测试页访问 | `curl -I https://te.hao.work/vision-lab` | 通过 |
|
||||||
| `http://te.hao.work:8302/` 4 位端口访问 | `curl -I http://te.hao.work:8302/` | 通过 |
|
| `http://te.hao.work:8302/` 4 位端口访问 | `curl -I http://te.hao.work:8302/` | 通过 |
|
||||||
| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 |
|
| 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 |
|
||||||
| 生产登录与首次进入工作台 | Playwright 登录真实站点并跳转 `/dashboard` | 通过 |
|
| 生产登录与首次进入工作台 | Playwright 登录真实站点并跳转 `/dashboard` | 通过 |
|
||||||
|
| 新用户邀请码校验 | Playwright 验证无邀请码被拦截、正确邀请码 `CA2026` 可创建新账号 | 通过 |
|
||||||
|
| 日志页访问 | Playwright 以 `H1` 登录并访问 `/logs` | 通过 |
|
||||||
| 生产训练 / 实时分析 / 录制 / 视频库页面加载 | Playwright 访问 `/training`、`/live-camera`、`/recorder`、`/videos` | 通过 |
|
| 生产训练 / 实时分析 / 录制 / 视频库页面加载 | Playwright 访问 `/training`、`/live-camera`、`/recorder`、`/videos` | 通过 |
|
||||||
| 生产训练计划后台任务提交 | Playwright 点击训练计划生成按钮并收到后台任务反馈 | 通过 |
|
| 生产训练计划后台任务提交 | Playwright 点击训练计划生成按钮并收到后台任务反馈 | 通过 |
|
||||||
| 生产移动端录制焦点视图 | Playwright 移动端视口打开 `/recorder` 并验证焦点入口与操作壳层 | 通过 |
|
| 生产移动端录制焦点视图 | Playwright 移动端视口打开 `/recorder` 并验证焦点入口与操作壳层 | 通过 |
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
|
| tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 |
|
||||||
| task 后台任务路由 | `pnpm test` / `pnpm test:e2e` | 通过 |
|
| task 后台任务路由 | `pnpm test` / `pnpm test:e2e` | 通过 |
|
||||||
| media 工具函数 | `pnpm test` | 通过 |
|
| media 工具函数 | `pnpm test` | 通过 |
|
||||||
|
| 媒体服务 `/media` 路径回退 | `pnpm test` | 通过 |
|
||||||
| 登录 URL 回退逻辑 | `pnpm test` | 通过 |
|
| 登录 URL 回退逻辑 | `pnpm test` | 通过 |
|
||||||
|
|
||||||
## Go 媒体服务验证
|
## Go 媒体服务验证
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const ENV = {
|
|||||||
appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "",
|
appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "",
|
||||||
cookieSecret: process.env.JWT_SECRET ?? "",
|
cookieSecret: process.env.JWT_SECRET ?? "",
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "",
|
databaseUrl: process.env.DATABASE_URL ?? "",
|
||||||
|
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE ?? "CA2026",
|
||||||
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "",
|
||||||
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
ownerOpenId: process.env.OWNER_OPEN_ID ?? "",
|
||||||
adminUsernames: parseList(process.env.ADMIN_USERNAMES),
|
adminUsernames: parseList(process.env.ADMIN_USERNAMES),
|
||||||
|
|||||||
15
server/db.ts
15
server/db.ts
@@ -82,7 +82,16 @@ export async function getUserByUsername(username: string) {
|
|||||||
return userResult.length > 0 ? userResult[0] : undefined;
|
return userResult.length > 0 ? userResult[0] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUsernameAccount(username: string): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
|
export function isValidRegistrationInvite(inviteCode?: string | null) {
|
||||||
|
const expected = ENV.registrationInviteCode.trim();
|
||||||
|
if (!expected) return true;
|
||||||
|
return (inviteCode ?? "").trim() === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUsernameAccount(
|
||||||
|
username: string,
|
||||||
|
inviteCode?: string,
|
||||||
|
): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
@@ -97,6 +106,10 @@ export async function createUsernameAccount(username: string): Promise<{ user: t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidRegistrationInvite(inviteCode)) {
|
||||||
|
throw new Error("新用户注册需要正确的邀请码");
|
||||||
|
}
|
||||||
|
|
||||||
// Create new user with username as openId
|
// Create new user with username as openId
|
||||||
const openId = `username_${username}_${Date.now()}`;
|
const openId = `username_${username}_${Date.now()}`;
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { appRouter } from "./routers";
|
import { appRouter } from "./routers";
|
||||||
import { COOKIE_NAME } from "../shared/const";
|
import { COOKIE_NAME } from "../shared/const";
|
||||||
import type { TrpcContext } from "./_core/context";
|
import type { TrpcContext } from "./_core/context";
|
||||||
import * as db from "./db";
|
import * as db from "./db";
|
||||||
|
import { ENV } from "./_core/env";
|
||||||
|
import { sdk } from "./_core/sdk";
|
||||||
|
|
||||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||||
|
|
||||||
@@ -114,6 +116,68 @@ describe("auth.loginWithUsername input validation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("auth.loginWithUsername invite flow", () => {
|
||||||
|
const originalInviteCode = ENV.registrationInviteCode;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
ENV.registrationInviteCode = "CA2026";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ENV.registrationInviteCode = originalInviteCode;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows existing users to log in without an invite code", async () => {
|
||||||
|
const existingUser = createTestUser({ name: "ExistingPlayer", openId: "existing-1" });
|
||||||
|
const { ctx, setCookies } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(existingUser);
|
||||||
|
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||||
|
user: existingUser,
|
||||||
|
isNew: false,
|
||||||
|
});
|
||||||
|
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||||
|
|
||||||
|
const result = await caller.auth.loginWithUsername({ username: "ExistingPlayer" });
|
||||||
|
|
||||||
|
expect(result.isNew).toBe(false);
|
||||||
|
expect(createUsernameAccountSpy).toHaveBeenCalledWith("ExistingPlayer", undefined);
|
||||||
|
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects new users without the correct invite code", async () => {
|
||||||
|
const { ctx } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||||
|
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount");
|
||||||
|
|
||||||
|
await expect(caller.auth.loginWithUsername({ username: "NewPlayer" })).rejects.toThrow("新用户注册需要正确的邀请码");
|
||||||
|
expect(createUsernameAccountSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows new users with the correct invite code", async () => {
|
||||||
|
const newUser = createTestUser({ name: "NewPlayer", openId: "new-1" });
|
||||||
|
const { ctx, setCookies } = createMockContext(null);
|
||||||
|
const caller = appRouter.createCaller(ctx);
|
||||||
|
|
||||||
|
vi.spyOn(db, "getUserByUsername").mockResolvedValueOnce(undefined);
|
||||||
|
const createUsernameAccountSpy = vi.spyOn(db, "createUsernameAccount").mockResolvedValueOnce({
|
||||||
|
user: newUser,
|
||||||
|
isNew: true,
|
||||||
|
});
|
||||||
|
vi.spyOn(sdk, "createSessionToken").mockResolvedValueOnce("session-token");
|
||||||
|
|
||||||
|
const result = await caller.auth.loginWithUsername({ username: "NewPlayer", inviteCode: "CA2026" });
|
||||||
|
|
||||||
|
expect(result.isNew).toBe(true);
|
||||||
|
expect(createUsernameAccountSpy).toHaveBeenCalledWith("NewPlayer", "CA2026");
|
||||||
|
expect(setCookies[0]?.name).toBe(COOKIE_NAME);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ===== PROFILE TESTS =====
|
// ===== PROFILE TESTS =====
|
||||||
|
|
||||||
describe("profile.stats", () => {
|
describe("profile.stats", () => {
|
||||||
|
|||||||
73
server/mediaService.test.ts
普通文件
73
server/mediaService.test.ts
普通文件
@@ -0,0 +1,73 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ENV } from "./_core/env";
|
||||||
|
import { getRemoteMediaSession } from "./mediaService";
|
||||||
|
|
||||||
|
const originalMediaServiceUrl = ENV.mediaServiceUrl;
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
ENV.mediaServiceUrl = originalMediaServiceUrl;
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRemoteMediaSession", () => {
|
||||||
|
it("falls back to /media-prefixed routes when the root route returns 404", async () => {
|
||||||
|
ENV.mediaServiceUrl = "http://127.0.0.1:8081";
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: vi.fn().mockResolvedValue("404 page not found\n"),
|
||||||
|
statusText: "Not Found",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "1",
|
||||||
|
title: "demo",
|
||||||
|
archiveStatus: "idle",
|
||||||
|
playback: {
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const session = await getRemoteMediaSession("session-1");
|
||||||
|
|
||||||
|
expect(session.id).toBe("session-1");
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(1, "http://127.0.0.1:8081/sessions/session-1");
|
||||||
|
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://127.0.0.1:8081/media/sessions/session-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the configured /media base URL directly when already present", async () => {
|
||||||
|
ENV.mediaServiceUrl = "http://media:8081/media";
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
id: "session-2",
|
||||||
|
userId: "2",
|
||||||
|
title: "demo",
|
||||||
|
archiveStatus: "processing",
|
||||||
|
playback: {
|
||||||
|
ready: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const session = await getRemoteMediaSession("session-2");
|
||||||
|
|
||||||
|
expect(session.id).toBe("session-2");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith("http://media:8081/media/sessions/session-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,12 +23,30 @@ function getMediaBaseUrl() {
|
|||||||
return ENV.mediaServiceUrl.replace(/\/+$/, "");
|
return ENV.mediaServiceUrl.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRemoteMediaSession(sessionId: string) {
|
function getMediaCandidateUrls(path: string) {
|
||||||
const response = await fetch(`${getMediaBaseUrl()}/sessions/${sessionId}`);
|
const baseUrl = getMediaBaseUrl();
|
||||||
if (!response.ok) {
|
if (baseUrl.endsWith("/media")) {
|
||||||
const message = await response.text().catch(() => response.statusText);
|
return [`${baseUrl}${path}`];
|
||||||
throw new Error(`Media service request failed (${response.status}): ${message}`);
|
|
||||||
}
|
}
|
||||||
|
return [`${baseUrl}${path}`, `${baseUrl}/media${path}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRemoteMediaSession(sessionId: string) {
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (const url of getMediaCandidateUrls(`/sessions/${encodeURIComponent(sessionId)}`)) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
const payload = await response.json() as { session: RemoteMediaSession };
|
const payload = await response.json() as { session: RemoteMediaSession };
|
||||||
return payload.session;
|
return payload.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await response.text().catch(() => response.statusText);
|
||||||
|
lastError = new Error(`Media service request failed (${response.status}): ${message}`);
|
||||||
|
if (response.status !== 404) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? new Error("Media service request failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getSessionCookieOptions } from "./_core/cookies";
|
import { getSessionCookieOptions } from "./_core/cookies";
|
||||||
import { systemRouter } from "./_core/systemRouter";
|
import { systemRouter } from "./_core/systemRouter";
|
||||||
import { adminProcedure, publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
import { adminProcedure, publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
||||||
@@ -48,11 +49,20 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Username-based login
|
// Username-based login
|
||||||
loginWithUsername: publicProcedure
|
loginWithUsername: publicProcedure
|
||||||
.input(z.object({ username: z.string().min(1).max(64) }))
|
.input(z.object({
|
||||||
|
username: z.string().trim().min(1).max(64),
|
||||||
|
inviteCode: z.string().trim().max(64).optional(),
|
||||||
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { user, isNew } = await db.createUsernameAccount(input.username);
|
const username = input.username.trim();
|
||||||
|
const existingUser = await db.getUserByUsername(username);
|
||||||
|
if (!existingUser && !db.isValidRegistrationInvite(input.inviteCode)) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "新用户注册需要正确的邀请码" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, isNew } = await db.createUsernameAccount(username, input.inviteCode);
|
||||||
const sessionToken = await sdk.createSessionToken(user.openId, {
|
const sessionToken = await sdk.createSessionToken(user.openId, {
|
||||||
name: user.name || input.username,
|
name: user.name || username,
|
||||||
expiresInMs: ONE_YEAR_MS,
|
expiresInMs: ONE_YEAR_MS,
|
||||||
});
|
});
|
||||||
const cookieOptions = getSessionCookieOptions(ctx.req);
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户