diff --git a/.env.example b/.env.example index 62503e1..ec89941 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ PORT=3000 # App auth / storage / database DATABASE_URL=mysql://tennis:replace-with-db-password@db:3306/tennis_training_hub JWT_SECRET=replace-with-strong-secret +REGISTRATION_INVITE_CODE=CA2026 VITE_APP_ID=tennis-training-hub OAUTH_SERVER_URL= OWNER_OPEN_ID= diff --git a/README.md b/README.md index 1682001..3c6dd2f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - 摄像头中断后自动重连,保留既有分段与会话 - Go 媒体 worker 将分段合并归档,并产出 WebM 回放;FFmpeg 可用时额外生成 MP4 - Node app worker 轮询媒体归档状态,归档完成后自动登记到视频库并向任务中心反馈结果 +- 服务端媒体会话校验兼容 `/media/sessions/...` 路径,避免录制结束时因路径不一致导致 404 ## Background Tasks @@ -34,6 +35,8 @@ 前端提供全局任务中心,页面本地也会显示任务提交、执行中、完成或失败状态。训练页、分析页和录制页都可以在用户离开页面后继续完成后台任务。 +另外提供独立日志页 `/logs`,用于查看后台任务历史、失败原因与通知记录。 + ## Multimodal LLM - 文本类任务使用 `LLM_API_URL` / `LLM_API_KEY` / `LLM_MODEL` @@ -42,6 +45,7 @@ - 若视觉模型链路不可用,系统会自动回退到结构化指标驱动的文本纠正,避免任务直接失败 - 系统内置“视觉标准图库”页面 `/vision-lab`,可把公网网球参考图入库并保存每次识别结果 - `ADMIN_USERNAMES` 可指定哪些用户名账号拥有 admin 视角,例如 `H1` +- 用户名登录支持直接进入系统;仅首次创建新用户时需要填写 `REGISTRATION_INVITE_CODE` ## Quick Start @@ -117,6 +121,7 @@ pnpm exec playwright install chromium - `DATABASE_URL` - `JWT_SECRET` - `ADMIN_USERNAMES` +- `REGISTRATION_INVITE_CODE` - `MYSQL_DATABASE` - `MYSQL_USER` - `MYSQL_PASSWORD` diff --git a/client/src/App.tsx b/client/src/App.tsx index 121c2e4..2852451 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -20,6 +20,7 @@ import Recorder from "./pages/Recorder"; import Tutorials from "./pages/Tutorials"; import Reminders from "./pages/Reminders"; import VisionLab from "./pages/VisionLab"; +import Logs from "./pages/Logs"; function DashboardRoute({ component: Component }: { component: React.ComponentType }) { return ( @@ -70,6 +71,9 @@ function Router() { + + + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index bcf8ae7..b7d1510 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -23,7 +23,7 @@ import { useIsMobile } from "@/hooks/useMobile"; import { LayoutDashboard, LogOut, PanelLeft, Target, Video, Award, Activity, FileVideo, Trophy, Flame, Camera, CircleDot, - BookOpen, Bell, Microscope + BookOpen, Bell, Microscope, ScrollText } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation, Redirect } from "wouter"; @@ -51,6 +51,7 @@ const menuItems: MenuItem[] = [ { icon: Trophy, label: "排行榜", path: "/leaderboard", group: "stats" }, { icon: BookOpen, label: "教程库", path: "/tutorials", 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 }, ]; diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx index 8042407..891abc3 100644 --- a/client/src/pages/Login.tsx +++ b/client/src/pages/Login.tsx @@ -9,6 +9,7 @@ import { Target, Loader2 } from "lucide-react"; export default function Login() { const [username, setUsername] = useState(""); + const [inviteCode, setInviteCode] = useState(""); const [, setLocation] = useLocation(); const utils = trpc.useUtils(); const loginMutation = trpc.auth.loginWithUsername.useMutation(); @@ -37,7 +38,10 @@ export default function Login() { } 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); toast.success(data.isNew ? `已创建用户:${user.name}` : `已登录:${user.name}`); setLocation("/dashboard"); @@ -77,6 +81,20 @@ export default function Login() { maxLength={64} /> +
+ setInviteCode(e.target.value)} + className="h-12 text-base" + maxLength={64} + /> +

+ 已存在账号只需输入用户名。新用户首次登录需要邀请码。 +

+
+ ) : task.status === "succeeded" ? ( + + + 已完成 + + ) : ( + + + 处理中 + + )} + + + + )) + )} + + + + + + +
+ {(notificationQuery.data ?? []).length === 0 ? ( + + + 还没有通知记录。 + + + ) : ( + (notificationQuery.data ?? []).map((item) => ( + + +
+
+ {item.title} + + {new Date(item.createdAt).toLocaleString("zh-CN")} · {item.notificationType} + +
+ + {formatNotificationState(item.isRead)} + +
+
+ +
+ +

{item.message || "无附加内容"}

+
+
+
+ )) + )} +
+
+
+ + + ); +} diff --git a/docs/verified-features.md b/docs/verified-features.md index db5c049..7dd54d0 100644 --- a/docs/verified-features.md +++ b/docs/verified-features.md @@ -1,22 +1,25 @@ # Verified Features -本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:40 CST。 +本文档记录当前已经通过自动化验证或构建验证的项目。更新时间:2026-03-15 00:52 CST。 ## 最新完整验证记录 - 通过命令:`pnpm verify` -- 验证时间:2026-03-15 00:39 CST -- 结果摘要:`pnpm check` 通过,`pnpm test` 通过(85/85),`pnpm test:go` 通过,`pnpm build` 通过,`pnpm test:e2e` 通过(6/6),`pnpm test:llm` 通过 +- 验证时间:2026-03-15 00:51 CST +- 结果摘要:`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/logs` 日志页访问 | `curl -I https://te.hao.work/logs` | 通过 | | `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/` | 通过 | | 站点 TLS 证书 | Let’s Encrypt ECDSA 证书已签发并由宿主机 nginx 加载 | 通过 | | 生产登录与首次进入工作台 | Playwright 登录真实站点并跳转 `/dashboard` | 通过 | +| 新用户邀请码校验 | Playwright 验证无邀请码被拦截、正确邀请码 `CA2026` 可创建新账号 | 通过 | +| 日志页访问 | Playwright 以 `H1` 登录并访问 `/logs` | 通过 | | 生产训练 / 实时分析 / 录制 / 视频库页面加载 | Playwright 访问 `/training`、`/live-camera`、`/recorder`、`/videos` | 通过 | | 生产训练计划后台任务提交 | Playwright 点击训练计划生成按钮并收到后台任务反馈 | 通过 | | 生产移动端录制焦点视图 | Playwright 移动端视口打开 `/recorder` 并验证焦点入口与操作壳层 | 通过 | @@ -49,6 +52,7 @@ | tutorial / reminder / notification 路由校验 | `pnpm test` | 通过 | | task 后台任务路由 | `pnpm test` / `pnpm test:e2e` | 通过 | | media 工具函数 | `pnpm test` | 通过 | +| 媒体服务 `/media` 路径回退 | `pnpm test` | 通过 | | 登录 URL 回退逻辑 | `pnpm test` | 通过 | ## Go 媒体服务验证 diff --git a/server/_core/env.ts b/server/_core/env.ts index cbeedc5..0640267 100644 --- a/server/_core/env.ts +++ b/server/_core/env.ts @@ -20,6 +20,7 @@ export const ENV = { appPublicBaseUrl: process.env.APP_PUBLIC_BASE_URL ?? "", cookieSecret: process.env.JWT_SECRET ?? "", databaseUrl: process.env.DATABASE_URL ?? "", + registrationInviteCode: process.env.REGISTRATION_INVITE_CODE ?? "CA2026", oAuthServerUrl: process.env.OAUTH_SERVER_URL ?? "", ownerOpenId: process.env.OWNER_OPEN_ID ?? "", adminUsernames: parseList(process.env.ADMIN_USERNAMES), diff --git a/server/db.ts b/server/db.ts index a8435de..e14a814 100644 --- a/server/db.ts +++ b/server/db.ts @@ -82,7 +82,16 @@ export async function getUserByUsername(username: string) { 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(); 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 const openId = `username_${username}_${Date.now()}`; await db.insert(users).values({ diff --git a/server/features.test.ts b/server/features.test.ts index 2e85dc2..77004e3 100644 --- a/server/features.test.ts +++ b/server/features.test.ts @@ -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 { COOKIE_NAME } from "../shared/const"; import type { TrpcContext } from "./_core/context"; import * as db from "./db"; +import { ENV } from "./_core/env"; +import { sdk } from "./_core/sdk"; type AuthenticatedUser = NonNullable; @@ -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 ===== describe("profile.stats", () => { diff --git a/server/mediaService.test.ts b/server/mediaService.test.ts new file mode 100644 index 0000000..4095ddd --- /dev/null +++ b/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"); + }); +}); diff --git a/server/mediaService.ts b/server/mediaService.ts index 15fcd12..c52c4d1 100644 --- a/server/mediaService.ts +++ b/server/mediaService.ts @@ -23,12 +23,30 @@ function getMediaBaseUrl() { return ENV.mediaServiceUrl.replace(/\/+$/, ""); } -export async function getRemoteMediaSession(sessionId: string) { - const response = await fetch(`${getMediaBaseUrl()}/sessions/${sessionId}`); - if (!response.ok) { - const message = await response.text().catch(() => response.statusText); - throw new Error(`Media service request failed (${response.status}): ${message}`); +function getMediaCandidateUrls(path: string) { + const baseUrl = getMediaBaseUrl(); + if (baseUrl.endsWith("/media")) { + return [`${baseUrl}${path}`]; } - const payload = await response.json() as { session: RemoteMediaSession }; - return payload.session; + 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 }; + 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"); } diff --git a/server/routers.ts b/server/routers.ts index e20f13f..e6d319c 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1,4 +1,5 @@ import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; +import { TRPCError } from "@trpc/server"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { adminProcedure, publicProcedure, protectedProcedure, router } from "./_core/trpc"; @@ -48,11 +49,20 @@ export const appRouter = router({ // Username-based login 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 }) => { - 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, { - name: user.name || input.username, + name: user.name || username, expiresInMs: ONE_YEAR_MS, }); const cookieOptions = getSessionCookieOptions(ctx.req);