From e95b1603c1b657966c1be567d0c744f055b6b021 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 14:34:30 +0800 Subject: [PATCH 01/46] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=20UI=20?= =?UTF-8?q?=E5=9B=BD=E9=99=85=E5=8C=96=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=AD?= =?UTF-8?q?/=E8=8B=B1/=E6=97=A5=E4=B8=89=E8=AF=AD=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前语言设置只影响后端 AI 行为,前端 UI 文字始终为中文。 新增轻量 i18n 模块(React Context + 翻译字典),切换语言后所有 UI 文字即时刷新。 - 新增 i18n 核心模块和 zh-CN/en-US/ja-JP 翻译文件(约 72 个 key) - App.tsx 拆分为 App(Provider)+ AppContent(业务),确保 Hook 可访问 Context - 所有组件通过 useI18n().t() 获取翻译文本 - errors.ts 改为接受翻译函数参数 - storage.ts saveConfig 时派发事件通知 locale 变化 --- frontend/src/App.tsx | 79 +++++++++++------ .../src/components/CameraManager/index.tsx | 4 +- frontend/src/components/ChatPanel/index.tsx | 16 ++-- frontend/src/components/ConfigPanel/index.tsx | 33 +++---- .../src/components/EdgeProcessor/index.tsx | 4 +- frontend/src/components/MicManager/index.tsx | 4 +- .../src/components/VideoPreview/index.tsx | 5 +- frontend/src/hooks/useVisionSession.ts | 12 +-- frontend/src/lib/errors.ts | 25 ++---- frontend/src/lib/i18n/en-US.ts | 86 +++++++++++++++++++ frontend/src/lib/i18n/index.ts | 47 ++++++++++ frontend/src/lib/i18n/ja-JP.ts | 86 +++++++++++++++++++ frontend/src/lib/i18n/zh-CN.ts | 86 +++++++++++++++++++ frontend/src/lib/storage.ts | 2 + 14 files changed, 418 insertions(+), 71 deletions(-) create mode 100644 frontend/src/lib/i18n/en-US.ts create mode 100644 frontend/src/lib/i18n/index.ts create mode 100644 frontend/src/lib/i18n/ja-JP.ts create mode 100644 frontend/src/lib/i18n/zh-CN.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58ac759..241bfd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,17 +2,20 @@ // CamTalk — 主应用组件(Web 端双栏布局) // ============================================================ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVisionSession } from "./hooks/useVisionSession"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { ToastContainer } from "./components/Toast"; -import { loadTheme, saveTheme } from "./lib/storage"; +import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; +import { I18nContext, parseLocale, t } from "./lib/i18n"; +import type { Locale } from "./lib/i18n"; import type { Theme } from "./types"; import "./App.css"; -function App() { +/** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */ +function AppContent() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); @@ -88,28 +91,30 @@ function App() { return () => clearInterval(id); }, [isConnected]); + const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]); + return (
{/* ---- 顶部导航栏 ---- */}

CamTalk

- AI 视觉对话助手 + {tr("app.title")}
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( - {stats.queryCount} 次请求 · {stats.totalTokens} tokens + {stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens )} - {isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"} + {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
)} {isConnected && !isCameraOn && ( @@ -179,8 +184,8 @@ function App() { - 摄像头未开启 - 可在右侧聊天框打字对话 + {tr("video.cameraOff")} + {tr("video.cameraOff.hint")}
)} @@ -189,21 +194,21 @@ function App() {
{!isConnected ? ( ) : (
@@ -211,15 +216,15 @@ function App() { className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`} onClick={toggleMode} > - {mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"} + {mode === "observation" ? tr("controls.observing") : tr("controls.observation")} {isProcessing && ( )}
)} @@ -229,15 +234,15 @@ function App() { {/* 右侧:聊天面板 */}
- 对话 + {tr("chat.title")} {isConnected && mode === "observation" && ( - 观察模式 + {tr("chat.mode.observation")} )}
{connectionStatus === "disconnected" && messages.length > 0 && (
- 连接已断开,正在重连... + {tr("chat.reconnecting")}
)} (() => parseLocale(loadConfig().language)); + + const i18nValue = useMemo(() => ({ + locale, + t: (key: string) => t(key, locale), + }), [locale]); + + // 监听配置变化以更新 locale + useEffect(() => { + const handler = () => { + const cfg = loadConfig(); + setLocale(parseLocale(cfg.language)); + }; + // 自定义事件,由 saveConfig 触发 + window.addEventListener("camtalk-config-changed", handler); + return () => window.removeEventListener("camtalk-config-changed", handler); + }, []); + + return ( + + + + ); +} + export default App; diff --git a/frontend/src/components/CameraManager/index.tsx b/frontend/src/components/CameraManager/index.tsx index 0cb76e0..7b950a4 100644 --- a/frontend/src/components/CameraManager/index.tsx +++ b/frontend/src/components/CameraManager/index.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useCallback, useRef, useState } from "react"; +import { useI18n } from "../../lib/i18n"; export interface CameraManagerHandle { /** 获取当前视频轨道 */ @@ -13,6 +14,7 @@ export interface CameraManagerHandle { } export function useCamera() { + const { t } = useI18n(); const videoRef = useRef(null); const [stream, setStream] = useState(null); const [error, setError] = useState(null); @@ -29,7 +31,7 @@ export function useCamera() { } setError(null); } catch (err) { - const message = err instanceof Error ? err.message : "无法访问摄像头"; + const message = err instanceof Error ? err.message : t("error.cameraAccess"); setError(message); console.error("[Camera] 获取摄像头失败:", err); } diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 5836226..e19ada5 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useEffect, useRef, useState } from "react"; +import { useI18n } from "../../lib/i18n"; import type { ChatMessage } from "../../types"; import type { ConnectionStatus } from "../../lib/websocket"; @@ -19,6 +20,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText const containerRef = useRef(null); const isAutoScroll = useRef(true); const [inputText, setInputText] = useState(""); + const { t } = useI18n(); const isConnected = connectionStatus === "connected"; @@ -56,8 +58,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText return (
💬 -

点击下方按钮开始对话

- 连接后可打字或语音与 AI 交互 +

{t("chat.empty.prompt")}

+ {t("chat.empty.hint")}
); } @@ -69,15 +71,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText {messages.length === 0 && !currentReply && (
💬 -

在下方输入文字开始对话

- 也可以开启麦克风用语音对话 +

{t("chat.welcome.prompt")}

+ {t("chat.welcome.hint")}
)} {messages.map((msg, index) => (
- {msg.role === "user" ? "你" : "AI"} + {msg.role === "user" ? t("chat.userLabel") : "AI"}
{msg.content}
{msg.role === "assistant" && msg.tokensUsed !== undefined && ( @@ -110,7 +112,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText setInputText(e.target.value)} /> @@ -118,7 +120,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText type="submit" className="chat-input__send" disabled={!inputText.trim()} - title="发送" + title={t("chat.send")} > ➤ diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx index 3844e6d..9523ec6 100644 --- a/frontend/src/components/ConfigPanel/index.tsx +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -3,6 +3,7 @@ // 职责:主题切换、TTS 开关、detail level 切换、语言选择 // ============================================================ +import { useI18n } from "../../lib/i18n"; import type { SessionConfig, Theme } from "../../types"; interface ConfigPanelProps { @@ -14,40 +15,42 @@ interface ConfigPanelProps { } export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) { + const { t } = useI18n(); + return (
e.stopPropagation()}>
- 设置 + {t("settings.title")}
-
外观
+
{t("settings.appearance")}
-
会话
+
{t("settings.session")}
- {isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( + {isConnected && stats.queryCount > 0 && ( - {stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens + {stats.queryCount} {tr("app.stats.requests")} )} -- 2.49.1 From 92f3f45c41d580bbb5827cb70598d587d51c8a38 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 15:44:25 +0800 Subject: [PATCH 04/46] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E4=BE=A7=E8=BE=B9=E6=A0=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E5=BB=BA/=E5=88=87=E6=8D=A2/?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E9=87=8D=E5=91=BD=E5=90=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 类似 ChatGPT 的侧边栏布局,消息历史持久化到 localStorage。 - 新增 SessionSummary 类型和 localStorage 读写函数 - 新增 useSessionList Hook 管理会话列表 CRUD - 新增 SessionSidebar 组件(展开/折叠、行内重命名) - App.tsx 协调 useSessionList 和 useVisionSession - 消息变化时自动持久化,切换/新建会话时保存并加载 - 新增 i18n 翻译 key(中/英/日) --- frontend/src/App.css | 187 ++++++++++++++++++ frontend/src/App.tsx | 88 ++++++++- .../src/components/SessionSidebar/index.tsx | 160 +++++++++++++++ frontend/src/hooks/useSessionList.ts | 117 +++++++++++ frontend/src/hooks/useVisionSession.ts | 1 + frontend/src/lib/i18n/en-US.ts | 9 + frontend/src/lib/i18n/ja-JP.ts | 9 + frontend/src/lib/i18n/zh-CN.ts | 9 + frontend/src/lib/storage.ts | 50 ++++- frontend/src/types/index.ts | 10 + 10 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/SessionSidebar/index.tsx create mode 100644 frontend/src/hooks/useSessionList.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 1577688..14f4965 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -208,6 +208,193 @@ body { overflow: hidden; } +/* ---- 会话历史侧边栏 ---- */ + +.sidebar { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--color-surface-1); + border-right: 1px solid var(--color-border); + overflow: hidden; +} + +.sidebar--collapsed { + width: 48px; + align-items: center; + padding: 8px 0; + gap: 8px; +} + +.sidebar__header { + display: flex; + align-items: center; + gap: 6px; + padding: 12px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.sidebar__toggle { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 1rem; + padding: 6px 8px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.sidebar__toggle:hover { + background: var(--color-surface-2); + color: var(--color-text); +} + +.sidebar__new-btn { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text); + cursor: pointer; + font-size: 0.82rem; + padding: 6px 10px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.sidebar__new-btn:hover { + background: var(--color-surface-2); +} + +.sidebar__new-btn--full { + flex: 1; + text-align: left; +} + +.sidebar__list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.sidebar__empty { + padding: 24px 12px; + text-align: center; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.sidebar__item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); + position: relative; +} + +.sidebar__item:hover { + background: var(--color-surface-2); +} + +.sidebar__item:hover .sidebar__item-actions { + opacity: 1; +} + +.sidebar__item--active { + background: var(--color-surface-2); + border-left: 3px solid var(--color-primary); +} + +.sidebar__item-content { + flex: 1; + min-width: 0; +} + +.sidebar__item-title { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text); +} + +.sidebar__item-meta { + font-size: 0.72rem; + color: var(--color-text-muted); + margin-top: 2px; + display: flex; + gap: 8px; +} + +.sidebar__item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity var(--transition-fast); + flex-shrink: 0; +} + +.sidebar__action-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + padding: 4px 6px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.sidebar__action-btn:hover { + background: var(--color-surface-3); + color: var(--color-text); +} + +.sidebar__action-btn--danger:hover { + color: #e74c3c; +} + +.sidebar__edit { + display: flex; + gap: 4px; + width: 100%; +} + +.sidebar__edit-input { + flex: 1; + font-size: 0.82rem; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-1); + color: var(--color-text); + outline: none; +} + +.sidebar__edit-input:focus { + border-color: var(--color-primary); +} + +.sidebar__edit-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + padding: 4px 8px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); +} + +.sidebar__edit-btn:hover { + background: var(--color-surface-2); + color: var(--color-text); +} + /* ---- 左侧视频面板 ---- */ .video-panel { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb41857..2ef06ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,14 @@ // ============================================================ -// CamTalk — 主应用组件(Web 端双栏布局) +// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天) // ============================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVisionSession } from "./hooks/useVisionSession"; +import { useSessionList } from "./hooks/useSessionList"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; +import { SessionSidebar } from "./components/SessionSidebar"; import { ToastContainer } from "./components/Toast"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; @@ -14,11 +16,12 @@ import type { Locale } from "./lib/i18n"; import type { Theme } from "./types"; import "./App.css"; -/** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */ +/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ function AppContent() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const timerRef = useRef | null>(null); // 切换主题时更新 的 data-theme 属性 @@ -37,8 +40,21 @@ function AppContent() { return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; }; + // ---- 会话列表 ---- + const { + sessions, + activeSessionId, + createSession, + deleteSession, + renameSession, + persistSession, + selectSession, + } = useSessionList(); + + // ---- 视觉会话 ---- const { messages, + setMessages, currentReply, isProcessing, isAudioPlaying, @@ -93,6 +109,58 @@ function AppContent() { const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]); + // ---- 初始化:如果没有会话,创建一个 ---- + const initializedRef = useRef(false); + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + if (sessions.length === 0) { + createSession(); + } + } + }, [sessions.length, createSession]); + + // ---- 自动保存:messages 变化时持久化到当前会话 ---- + const messagesRef = useRef(messages); + useEffect(() => { messagesRef.current = messages; }, [messages]); + + useEffect(() => { + if (activeSessionId && messages.length > 0) { + persistSession(activeSessionId, messages); + } + }, [messages, activeSessionId, persistSession]); + + // ---- 侧边栏操作 ---- + const handleNewSession = useCallback(() => { + createSession(); + setMessages([]); + // 如果已连接,断开 + if (connectionStatus === "connected") { + stopSession(); + } + }, [createSession, setMessages, connectionStatus, stopSession]); + + const handleSelectSession = useCallback((id: string) => { + // 保存当前会话 + if (activeSessionId && messagesRef.current.length > 0) { + persistSession(activeSessionId, messagesRef.current); + } + // 如果已连接,断开 + if (connectionStatus === "connected") { + stopSession(); + } + // 加载目标会话 + const loaded = selectSession(id); + setMessages(loaded); + }, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]); + + const handleDeleteSession = useCallback((id: string) => { + deleteSession(id); + if (id === activeSessionId) { + setMessages([]); + } + }, [deleteSession, activeSessionId, setMessages]); + return (
{/* ---- 顶部导航栏 ---- */} @@ -134,9 +202,21 @@ function AppContent() { /> )} - {/* ---- 主体:左侧视频 + 右侧聊天 ---- */} + {/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
- {/* 左侧:视频预览 + 控制栏 */} + {/* 最左侧:会话历史侧边栏 */} + setSidebarCollapsed((v) => !v)} + onNewSession={handleNewSession} + onSelectSession={handleSelectSession} + onDeleteSession={handleDeleteSession} + onRenameSession={renameSession} + /> + + {/* 中间:视频预览 + 控制栏 */}
diff --git a/frontend/src/components/SessionSidebar/index.tsx b/frontend/src/components/SessionSidebar/index.tsx new file mode 100644 index 0000000..7fedd71 --- /dev/null +++ b/frontend/src/components/SessionSidebar/index.tsx @@ -0,0 +1,160 @@ +// ============================================================ +// SessionSidebar — 会话历史侧边栏 +// 职责:展示会话列表、新建/切换/删除/重命名会话 +// ============================================================ + +import { useState } from "react"; +import { useI18n } from "../../lib/i18n"; +import type { SessionSummary } from "../../types"; + +interface SessionSidebarProps { + sessions: SessionSummary[]; + activeSessionId: string | null; + collapsed: boolean; + onToggleCollapse: () => void; + onNewSession: () => void; + onSelectSession: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, title: string) => void; +} + +/** 格式化相对时间 */ +function formatRelativeTime(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const min = 60 * 1000; + const hour = 60 * min; + const day = 24 * hour; + + if (diff < min) return "刚刚"; + if (diff < hour) return `${Math.floor(diff / min)}分钟前`; + if (diff < day) return `${Math.floor(diff / hour)}小时前`; + if (diff < 2 * day) return "昨天"; + if (diff < 7 * day) return `${Math.floor(diff / day)}天前`; + const d = new Date(ts); + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +export function SessionSidebar({ + sessions, + activeSessionId, + collapsed, + onToggleCollapse, + onNewSession, + onSelectSession, + onDeleteSession, + onRenameSession, +}: SessionSidebarProps) { + const { t } = useI18n(); + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(""); + + const handleStartRename = (id: string, currentTitle: string) => { + setEditingId(id); + setEditTitle(currentTitle); + }; + + const handleConfirmRename = () => { + if (editingId && editTitle.trim()) { + onRenameSession(editingId, editTitle.trim()); + } + setEditingId(null); + setEditTitle(""); + }; + + const handleCancelRename = () => { + setEditingId(null); + setEditTitle(""); + }; + + if (collapsed) { + return ( +
+ + +
+ ); + } + + return ( +
+
+ + +
+ +
+ {sessions.length === 0 ? ( +
{t("sidebar.empty")}
+ ) : ( + sessions.map((session) => ( +
onSelectSession(session.id)} + > + {editingId === session.id ? ( +
e.stopPropagation()}> + setEditTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirmRename(); + if (e.key === "Escape") handleCancelRename(); + }} + autoFocus + /> + + +
+ ) : ( + <> +
+
{session.title}
+
+ {session.messageCount > 0 && ( + {session.messageCount}{t("sidebar.messages")} + )} + {formatRelativeTime(session.lastActiveAt)} +
+
+
+ + +
+ + )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/hooks/useSessionList.ts b/frontend/src/hooks/useSessionList.ts new file mode 100644 index 0000000..2ab76c3 --- /dev/null +++ b/frontend/src/hooks/useSessionList.ts @@ -0,0 +1,117 @@ +// ============================================================ +// useSessionList — 会话历史列表管理 +// 职责:会话 CRUD、消息持久化、切换会话 +// ============================================================ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { + loadSessionSummaries, + saveSessionSummaries, + loadSessionMessages, + saveSessionMessages, + deleteSessionMessages, +} from "../lib/storage"; +import type { ChatMessage, SessionSummary } from "../types"; + +/** 截取预览文本 */ +function getPreview(text: string, maxLen = 50): string { + const clean = text.replace(/[\n\r]/g, " ").trim(); + return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean; +} + +export function useSessionList() { + const [sessions, setSessions] = useState(() => loadSessionSummaries()); + const [activeSessionId, setActiveSessionId] = useState(null); + const sessionsRef = useRef(sessions); + useEffect(() => { sessionsRef.current = sessions; }, [sessions]); + + /** 创建新会话 */ + const createSession = useCallback((): string => { + const id = uuidv4(); + const now = Date.now(); + const summary: SessionSummary = { + id, + title: "新对话", + createdAt: now, + lastActiveAt: now, + messageCount: 0, + preview: "", + }; + setSessions((prev) => [summary, ...prev]); + setActiveSessionId(id); + // 持久化 + const all = [summary, ...sessionsRef.current]; + saveSessionSummaries(all); + return id; + }, []); + + /** 删除会话 */ + const deleteSession = useCallback((id: string) => { + setSessions((prev) => prev.filter((s) => s.id !== id)); + deleteSessionMessages(id); + const remaining = sessionsRef.current.filter((s) => s.id !== id); + saveSessionSummaries(remaining); + // 如果删除的是当前会话,清空 active + setActiveSessionId((prev) => (prev === id ? null : prev)); + }, []); + + /** 重命名会话 */ + const renameSession = useCallback((id: string, title: string) => { + setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s))); + const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s)); + saveSessionSummaries(updated); + }, []); + + /** 保存当前会话的消息历史 */ + const saveCurrentSession = useCallback((messages: ChatMessage[]) => { + const id = sessionsRef.current.length > 0 ? sessionsRef.current[0].id : null; + // 找到 activeSessionId 对应的会话 + // 这里不依赖 activeSessionId state,而是通过参数传入 + return messages; + }, []); + + /** 保存指定会话的消息并更新摘要 */ + const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => { + if (!sessionId) return; + saveSessionMessages(sessionId, messages); + // 更新摘要 + const firstUserMsg = messages.find((m) => m.role === "user"); + const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话"; + const lastMsg = messages[messages.length - 1]; + const summary: Partial = { + title, + messageCount: messages.length, + lastActiveAt: lastMsg?.timestamp || Date.now(), + preview: lastMsg ? getPreview(lastMsg.content) : "", + }; + setSessions((prev) => { + const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s)); + saveSessionSummaries(updated); + return updated; + }); + }, []); + + /** 加载指定会话的消息历史 */ + const loadMessages = useCallback((sessionId: string): ChatMessage[] => { + return loadSessionMessages(sessionId); + }, []); + + /** 选择会话(返回需要加载的消息) */ + const selectSession = useCallback((id: string): ChatMessage[] => { + setActiveSessionId(id); + return loadSessionMessages(id); + }, []); + + return { + sessions, + activeSessionId, + setActiveSessionId, + createSession, + deleteSession, + renameSession, + persistSession, + loadMessages, + selectSession, + }; +} diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 0cc65fd..739afbf 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -458,6 +458,7 @@ export function useVisionSession() { return { messages, + setMessages, currentReply, isProcessing, isAudioPlaying, diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index 36b9415..eec5531 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -81,6 +81,15 @@ export const enUS: TranslationMap = { "error.INTERNAL_ERROR": "Internal server error, please retry", "error.unknown": "Unknown error", + // Sidebar + "sidebar.new": "New Chat", + "sidebar.expand": "Expand sidebar", + "sidebar.collapse": "Collapse sidebar", + "sidebar.empty": "No chat history", + "sidebar.messages": " messages", + "sidebar.rename": "Rename", + "sidebar.delete": "Delete", + // Device errors "error.vadInit": "VAD initialization failed", "error.cameraAccess": "Cannot access camera", diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 66f9419..947973b 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -81,6 +81,15 @@ export const jaJP: TranslationMap = { "error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください", "error.unknown": "不明なエラー", + // Sidebar + "sidebar.new": "新しいチャット", + "sidebar.expand": "サイドバーを展開", + "sidebar.collapse": "サイドバーを折りたたむ", + "sidebar.empty": "会話履歴がありません", + "sidebar.messages": "件のメッセージ", + "sidebar.rename": "名前を変更", + "sidebar.delete": "削除", + // Device errors "error.vadInit": "VAD初期化に失敗しました", "error.cameraAccess": "カメラにアクセスできません", diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index f4a1c34..d085cde 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -81,6 +81,15 @@ export const zhCN: TranslationMap = { "error.INTERNAL_ERROR": "服务内部错误,请重试", "error.unknown": "未知错误", + // Sidebar + "sidebar.new": "新对话", + "sidebar.expand": "展开侧边栏", + "sidebar.collapse": "收起侧边栏", + "sidebar.empty": "暂无会话记录", + "sidebar.messages": "条消息", + "sidebar.rename": "重命名", + "sidebar.delete": "删除", + // Device errors "error.vadInit": "VAD 初始化失败", "error.cameraAccess": "无法访问摄像头", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4c78a0d..e7cf6c5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -3,7 +3,7 @@ // 职责:会话配置持久化 // ============================================================ -import type { SessionConfig, Theme } from "../types"; +import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types"; const CONFIG_KEY = "camtalk:config"; const THEME_KEY = "camtalk:theme"; @@ -52,3 +52,51 @@ export function saveTheme(theme: Theme): void { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ } } + +// ---- 会话历史存储 ---- + +const SESSIONS_KEY = "camtalk:sessions"; +const SESSION_MSG_PREFIX = "camtalk:session:"; + +/** 加载所有会话摘要(按 lastActiveAt 倒序) */ +export function loadSessionSummaries(): SessionSummary[] { + try { + const raw = localStorage.getItem(SESSIONS_KEY); + if (!raw) return []; + return (JSON.parse(raw) as SessionSummary[]).sort((a, b) => b.lastActiveAt - a.lastActiveAt); + } catch { + return []; + } +} + +/** 保存会话摘要列表 */ +export function saveSessionSummaries(sessions: SessionSummary[]): void { + try { + localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); + } catch { /* ignore */ } +} + +/** 加载单个会话的消息历史 */ +export function loadSessionMessages(sessionId: string): ChatMessage[] { + try { + const raw = localStorage.getItem(SESSION_MSG_PREFIX + sessionId); + if (!raw) return []; + return JSON.parse(raw) as ChatMessage[]; + } catch { + return []; + } +} + +/** 保存单个会话的消息历史 */ +export function saveSessionMessages(sessionId: string, messages: ChatMessage[]): void { + try { + localStorage.setItem(SESSION_MSG_PREFIX + sessionId, JSON.stringify(messages)); + } catch { /* ignore */ } +} + +/** 删除单个会话的消息历史 */ +export function deleteSessionMessages(sessionId: string): void { + try { + localStorage.removeItem(SESSION_MSG_PREFIX + sessionId); + } catch { /* ignore */ } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 935fd22..14e94b8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -19,6 +19,16 @@ export interface Session { config: SessionConfig; } +/** 会话摘要(侧边栏列表用) */ +export interface SessionSummary { + id: string; + title: string; + createdAt: number; + lastActiveAt: number; + messageCount: number; + preview: string; +} + // ---- 聊天消息 ---- export interface ChatMessage { -- 2.49.1 From fe053f94dce42562fb448b8e6a7107711678ff7c Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 15:56:59 +0800 Subject: [PATCH 05/46] =?UTF-8?q?fix:=20=E9=9A=90=E8=97=8F=E8=A7=82?= =?UTF-8?q?=E5=AF=9F=E6=A8=A1=E5=BC=8F=E6=8C=89=E9=92=AE=EF=BC=8C=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=20toggleMode=20?= =?UTF-8?q?=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2ef06ad..bc607b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,6 @@ function AppContent() { stats, mode, isObserving, - toggleMode, startSession, stopSession, interrupt, @@ -292,12 +291,6 @@ function AppContent() { > 🎤 - {isProcessing && (

CamTalk

{tr("app.title")}
- {isConnected && stats.queryCount > 0 && ( - - {stats.queryCount} {tr("app.stats.requests")} - - )} {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")} @@ -191,6 +233,18 @@ function AppContent() {
+ {/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */} + setSidebarOpen((v) => !v)} + onNewSession={handleNewSession} + onSelectSession={handleSelectSession} + onDeleteSession={handleDeleteSession} + onRenameSession={renameSession} + /> + {showConfig && ( )} - {/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */} + {/* ---- 主体:视频 + 聊天(两栏) ---- */}
- {/* 最左侧:会话历史侧边栏 */} - setSidebarCollapsed((v) => !v)} - onNewSession={handleNewSession} - onSelectSession={handleSelectSession} - onDeleteSession={handleDeleteSession} - onRenameSession={renameSession} - /> - - {/* 中间:视频预览 + 控制栏 */} + {/* 左侧:视频预览 + 控制栏 */}
@@ -228,8 +270,15 @@ function AppContent() { {config.detailLevel === "high" && (
HD
)} - {isObserving && ( -
{tr("video.observing")}
+ {/* AI 视觉状态指示 */} + {isConnected && visionMode !== "chat" && ( +
+ + + + + {isObserving ? tr("video.observing") : "AI"} +
)} {isSpeaking && (
{tr("video.listening")}
@@ -269,37 +318,110 @@ function AppContent() { )}
- {/* 视频下方控制栏 */} + {/* 视频下方控制区(三层结构) */}
{!isConnected ? ( - - ) : ( -
- - - {isProcessing && ( - - )} - -
+ + +
+ + ) : ( + <> + {/* 通话态:核心控制工具栏 */} +
+ + + {/* 识别画面按钮(按需模式下显示) */} + {visionMode === "ondemand" && ( + + )} + {isProcessing && ( + + )} + +
+ {/* 通话态模式切换 */} +
+ + + +
+ )}
@@ -308,9 +430,18 @@ function AppContent() {
{tr("chat.title")} - {isConnected && mode === "observation" && ( - {tr("chat.mode.observation")} - )} +
+ {isConnected && mode === "observation" && ( + {tr("chat.mode.observation")} + )} + {isConnected && stats.queryCount > 0 && ( + + {stats.queryCount} {tr("statusbar.recognitions")} + {stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`} + {` · ${formatTime(elapsed)}`} + + )} +
{connectionStatus === "disconnected" && messages.length > 0 && ( @@ -322,7 +453,11 @@ function AppContent() { messages={messages} currentReply={currentReply} connectionStatus={connectionStatus} + isMicOn={isMicOn} + isSpeaking={isSpeaking} onSendText={sendTextMessage} + onToggleMic={toggleMic} + onSceneCard={handleSceneCard} />
diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 4ff7902..5be520a 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -1,6 +1,7 @@ // ============================================================ // ChatPanel — 消息展示面板 // 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入 +// 增强:空状态场景卡片、语音输入按钮 // ============================================================ import { useEffect, useRef, useState } from "react"; @@ -12,10 +13,33 @@ interface ChatPanelProps { messages: ChatMessage[]; currentReply?: string; connectionStatus: ConnectionStatus; + isMicOn?: boolean; + isSpeaking?: boolean; onSendText?: (text: string) => void; + onToggleMic?: () => void; + onSceneCard?: (prompt: string) => void; } -export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) { +/** 场景卡片数据 */ +function getSceneCards(t: (key: string) => string) { + return [ + { icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") }, + { icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") }, + { icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") }, + { icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") }, + ]; +} + +export function ChatPanel({ + messages, + currentReply, + connectionStatus, + isMicOn, + isSpeaking, + onSendText, + onToggleMic, + onSceneCard, +}: ChatPanelProps) { const bottomRef = useRef(null); const containerRef = useRef(null); const isAutoScroll = useRef(true); @@ -23,6 +47,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText const { t } = useI18n(); const isConnected = connectionStatus === "connected"; + const isEmpty = messages.length === 0 && !currentReply; + const sceneCards = getSceneCards(t); // 用户上滚时暂停自动滚动,滚到底部时恢复 useEffect(() => { @@ -53,15 +79,41 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText setInputText(""); }; + // 点击场景卡片 + const handleSceneCard = (prompt: string) => { + if (onSceneCard) { + onSceneCard(prompt); + } else if (onSendText) { + onSendText(prompt); + } + }; + return (
- {/* 空状态提示 */} - {messages.length === 0 && !currentReply && ( + {/* 空状态:场景卡片 */} + {isEmpty && (
💬

{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}

{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")} + + {/* 场景快捷卡片 */} +
+ {sceneCards.map((card) => ( + + ))} +
)} @@ -106,6 +158,17 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText onChange={(e) => setInputText(e.target.value)} disabled={connectionStatus === "connecting"} /> + {/* 语音输入按钮 */} + {onToggleMic && ( + + )} - -
- ); - } + const handleSelect = (id: string) => { + onSelectSession(id); + // 选择后自动收起侧边栏 + onToggle(); + }; + + // 过滤 + 分组 + const grouped = useMemo(() => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const filtered = searchQuery.trim() + ? sessions.filter((s) => + s.title.toLowerCase().includes(searchQuery.toLowerCase()) || + s.preview.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : sessions; + + const groups: Record = { + today: [], + yesterday: [], + earlier: [], + }; + + for (const session of filtered) { + const group = getTimeGroup(session.lastActiveAt, today, yesterday); + groups[group].push(session); + } + + return groups; + }, [sessions, searchQuery]); + + if (!open) return null; + + const groupLabels: Record = { + today: t("sidebar.today"), + yesterday: t("sidebar.yesterday"), + earlier: t("sidebar.earlier"), + }; return ( -
-
- - -
+ <> +
+
+ {/* 头部:新建 + 收起 */} +
+ + +
-
- {sessions.length === 0 ? ( -
{t("sidebar.empty")}
- ) : ( - sessions.map((session) => ( -
onSelectSession(session.id)} - > - {editingId === session.id ? ( -
e.stopPropagation()}> - setEditTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleConfirmRename(); - if (e.key === "Escape") handleCancelRename(); - }} - autoFocus - /> - - -
- ) : ( - <> -
-
{session.title}
-
- {session.messageCount > 0 && ( - {session.messageCount}{t("sidebar.messages")} + {/* 搜索栏 */} +
+ setSearchQuery(e.target.value)} + /> +
+ + {/* 会话列表(按时间分组) */} +
+ {sessions.length === 0 ? ( +
{t("sidebar.empty")}
+ ) : ( + (["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => { + const items = grouped[groupKey]; + if (items.length === 0) return null; + return ( +
+
{groupLabels[groupKey]}
+ {items.map((session) => ( +
handleSelect(session.id)} + > + {editingId === session.id ? ( +
e.stopPropagation()}> + setEditTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirmRename(); + if (e.key === "Escape") handleCancelRename(); + }} + autoFocus + /> + + +
+ ) : ( + <> + {/* 视频标记图标 */} + {session.messageCount > 5 && ( + 📹 + )} +
+
{session.title}
+
+ {session.messageCount > 0 && ( + {session.messageCount}{t("sidebar.messages")} + )} + {formatRelativeTime(session.lastActiveAt)} +
+
+
+ + +
+ )} - {formatRelativeTime(session.lastActiveAt)}
-
-
- - -
- - )} -
- )) - )} + ))} +
+ ); + }) + )} +
-
+ ); } diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 739afbf..497b996 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -482,5 +482,6 @@ export function useVisionSession() { toggleCamera, toggleMic, sendTextMessage, + captureFrame, }; } diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index eec5531..b24bcb4 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -89,6 +89,42 @@ export const enUS: TranslationMap = { "sidebar.messages": " messages", "sidebar.rename": "Rename", "sidebar.delete": "Delete", + "sidebar.search": "Search conversations...", + "sidebar.today": "Today", + "sidebar.yesterday": "Yesterday", + "sidebar.earlier": "Earlier", + "sidebar.pinned": "Pinned", + "sidebar.video": "Video", + "sidebar.clearHistory": "Clear History", + + // Scene cards (empty state) + "scene.describe": "Describe my surroundings", + "scene.describe.desc": "Let AI observe and describe the current scene", + "scene.text": "Recognize text in view", + "scene.text.desc": "Extract and translate text from the scene", + "scene.object": "Analyze this object", + "scene.object.desc": "Identify objects and provide information", + "scene.suggest": "Give me suggestions", + "scene.suggest.desc": "Practical suggestions based on the current scene", + + // Video controls (enhanced) + "controls.recognize": "Analyze Scene", + "controls.device.camera": "Camera", + "controls.device.mic": "Microphone", + "controls.device.default": "Default", + "controls.mode.realtime": "Realtime", + "controls.mode.ondemand": "On-demand", + "controls.mode.chat": "Chat only", + + // Status bar + "statusbar.ready": "Ready · Select devices to start", + "statusbar.calling": "In call", + "statusbar.duration": "Duration", + "statusbar.recognitions": "recognitions", + "statusbar.tokens": "Tokens", + + // Input enhanced + "chat.input.voice": "Voice input", // Device errors "error.vadInit": "VAD initialization failed", diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 947973b..37bc1e8 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -89,6 +89,42 @@ export const jaJP: TranslationMap = { "sidebar.messages": "件のメッセージ", "sidebar.rename": "名前を変更", "sidebar.delete": "削除", + "sidebar.search": "会話を検索...", + "sidebar.today": "今日", + "sidebar.yesterday": "昨日", + "sidebar.earlier": "それ以前", + "sidebar.pinned": "ピン留め", + "sidebar.video": "ビデオ", + "sidebar.clearHistory": "履歴をクリア", + + // Scene cards (empty state) + "scene.describe": "周囲のシーンを説明して", + "scene.describe.desc": "AIが現在のシーンを観察して説明します", + "scene.text": "画面のテキストを認識", + "scene.text.desc": "シーン内のテキストを抽出・翻訳します", + "scene.object": "この物を分析して", + "scene.object.desc": "物体を識別して関連情報を提供します", + "scene.suggest": "アドバイスをください", + "scene.suggest.desc": "現在のシーンに基づいた実用的な提案", + + // Video controls (enhanced) + "controls.recognize": "シーンを分析", + "controls.device.camera": "カメラ", + "controls.device.mic": "マイク", + "controls.device.default": "デフォルト", + "controls.mode.realtime": "リアルタイム", + "controls.mode.ondemand": "オンデマンド", + "controls.mode.chat": "チャットのみ", + + // Status bar + "statusbar.ready": "準備完了 · デバイスを選択して開始", + "statusbar.calling": "通話中", + "statusbar.duration": "通話時間", + "statusbar.recognitions": "回の認識", + "statusbar.tokens": "トークン", + + // Input enhanced + "chat.input.voice": "音声入力", // Device errors "error.vadInit": "VAD初期化に失敗しました", diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index d085cde..1f49ae0 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -89,6 +89,42 @@ export const zhCN: TranslationMap = { "sidebar.messages": "条消息", "sidebar.rename": "重命名", "sidebar.delete": "删除", + "sidebar.search": "搜索对话...", + "sidebar.today": "今天", + "sidebar.yesterday": "昨天", + "sidebar.earlier": "更早", + "sidebar.pinned": "置顶", + "sidebar.video": "视频", + "sidebar.clearHistory": "清除历史", + + // Scene cards (empty state) + "scene.describe": "描述我面前的场景", + "scene.describe.desc": "让 AI 观察并描述当前画面", + "scene.text": "识别画面中的文字", + "scene.text.desc": "提取并翻译画面里的文字", + "scene.object": "帮我分析这个物品", + "scene.object.desc": "识别物体并给出相关信息", + "scene.suggest": "给我一些建议", + "scene.suggest.desc": "基于当前场景给出实用建议", + + // Video controls (enhanced) + "controls.recognize": "识别画面", + "controls.device.camera": "摄像头", + "controls.device.mic": "麦克风", + "controls.device.default": "默认", + "controls.mode.realtime": "实时分析", + "controls.mode.ondemand": "按需识别", + "controls.mode.chat": "纯聊天", + + // Status bar + "statusbar.ready": "就绪 · 选择设备后开始通话", + "statusbar.calling": "通话中", + "statusbar.duration": "通话时长", + "statusbar.recognitions": "次识别", + "statusbar.tokens": "Token", + + // Input enhanced + "chat.input.voice": "语音输入", // Device errors "error.vadInit": "VAD 初始化失败", -- 2.49.1 From 4b30e67c2ef7999d41d38caa4726e18540798213 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:40:06 +0800 Subject: [PATCH 07/46] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Config=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=BD=93=EF=BC=8C=E6=96=B0=E5=A2=9E=20AuthCo?= =?UTF-8?q?nfig=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AuthConfig 结构体(JWTSecret, AccessTTL, RefreshTTL) - 在 Config 中添加 Auth 字段 - 设置默认值:access_ttl=15分钟,refresh_ttl=10080分钟(7天) - JWTSecret 必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置 --- backend/internal/config/config.go | 10 + docs/04-技术选型.md | 83 ++++ docs/11-持久化与用户系统设计.md | 750 ++++++++++++++++++++++++++++ docs/PLAN_USER_MODULE.md | 797 ++++++++++++++++++++++++++++++ 4 files changed, 1640 insertions(+) create mode 100644 docs/11-持久化与用户系统设计.md create mode 100644 docs/PLAN_USER_MODULE.md diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e0937a8..1949f0d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { AI AIConfig `mapstructure:"ai"` Storage StorageConfig `mapstructure:"storage"` Log LogConfig `mapstructure:"log"` + Auth AuthConfig `mapstructure:"auth"` } // SessionConfig 会话管理配置。 @@ -99,6 +100,13 @@ type LogConfig struct { Format string `mapstructure:"format"` } +// AuthConfig 认证配置。 +type AuthConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` // JWT 签名密钥,必须通过环境变量 CAMTALK_AUTH_JWT_SECRET 设置 + AccessTTL int `mapstructure:"access_ttl"` // Access Token 过期时间(分钟),默认 15 + RefreshTTL int `mapstructure:"refresh_ttl"` // Refresh Token 过期时间(分钟),默认 10080(7天) +} + // Load 加载配置。优先级:环境变量 > config.{env}.yaml > config.yaml。 func Load() (*Config, error) { v := viper.New() @@ -146,6 +154,8 @@ func Load() (*Config, error) { v.SetDefault("storage.driver", "memory") v.SetDefault("log.level", "info") v.SetDefault("log.format", "console") + v.SetDefault("auth.access_ttl", 15) + v.SetDefault("auth.refresh_ttl", 10080) // 读取基础配置文件 _ = v.ReadInConfig() // 文件不存在不报错 diff --git a/docs/04-技术选型.md b/docs/04-技术选型.md index 742e59a..8875fac 100644 --- a/docs/04-技术选型.md +++ b/docs/04-技术选型.md @@ -13,6 +13,12 @@ │ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型 │ └── TTS: OpenAI TTS(默认) / MiMo TTS ├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储) +├── 认证与用户系统 +│ ├── 认证方案: JWT (HS256), access 15min + refresh 7day +│ ├── JWT 库: golang-jwt/jwt/v5 +│ ├── 密码哈希: bcrypt +│ ├── 数据库驱动: pgx/v5(手写 SQL,不用 ORM) +│ └── 前端 Token 存储: localStorage └── 前端边缘处理层 ├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较) ├── 语音检测: @ricky0123/vad-web @@ -214,3 +220,80 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC | MediaDevices API | 直接用浏览器原生接口,不加封装层 | 与"前端做轻量预处理"原则一致:前端层只需采集和判断"有没有值得发给后端的数据"。 + +--- + +## 四、认证与用户系统选型 + +### 总览 + +| 能力 | 选型 | 选择理由 | +|------|------|---------| +| 认证方案 | JWT (HS256) | 无状态,分布式友好,实现简单 | +| JWT 库 | golang-jwt/jwt/v5 | 社区主流,v5 活跃维护 | +| 密码哈希 | bcrypt | Go 标准库直接可用,安全性足够 | +| 数据库驱动 | pgx/v5 | Go 生态性能最优的 PostgreSQL 驱动 | +| 数据库迁移 | 手写 SQL | MVP 阶段足够,后续可引入 golang-migrate | + +### 认证方案:JWT + +| 方案 | 特点 | 适用场景 | +|------|------|---------| +| **JWT (HS256)** | 无状态 token,服务端不存 session,水平扩展友好 | 分布式部署、前后端分离 | +| Session + Cookie | 有状态,服务端存 session(通常 Redis) | 传统 Web 应用、需要服务端控制会话 | +| OAuth2 | 第三方登录授权 | 需要接入微信/GitHub 等第三方登录 | + +选择 JWT 的核心理由:项目架构是前后端分离 + WebSocket 长连接,JWT 无需服务端维护 session 状态,天然适配。HS256 对称签名足以满足安全需求,实现比 RS256 简单。 + +Token 策略采用 **access (15min) + refresh (7day) 双 token**:access_token 短生命周期降低泄露风险,refresh_token 支持无感续期。 + +### JWT 库:golang-jwt/jwt/v5 + +| 方案 | 状态 | 特点 | +|------|------|------| +| **golang-jwt/jwt/v5** | 活跃维护 | dgrijalva/jwt-go 的官方继任,社区主流 | +| dgrijalva/jwt-go | 已停维护 | 原始库,不再更新 | +| lestrrat-go/jwx | 活跃 | 功能更全(JWE/JWS),但项目只需签名,过度引入 | + +v5 是 Go 生态中 JWT 的事实标准,API 简洁,文档完善。 + +### 密码哈希:bcrypt + +| 方案 | 特点 | 选择理由 | +|------|------|---------| +| **bcrypt** | 自适应 cost factor,抗暴力破解 | Go 标准库 `golang.org/x/crypto/bcrypt` 直接可用 | +| argon2 | 2015 年密码哈希竞赛冠军,抗 GPU/ASIC | 安全性更高,但 Go 生态库不如 bcrypt 成熟 | +| scrypt | 内存硬哈希 | 参数调优复杂,bcrypt 已足够 | + +bcrypt 的 `cost` 参数可随硬件升级调大,当前默认 cost=10 足够安全。 + +### 数据库驱动:pgx/v5 + +| 方案 | 特点 | 适用场景 | +|------|------|---------| +| **pgx/v5** | 原生 PostgreSQL 协议实现,连接池 pgxpool,性能最优 | 需要高性能、直接写 SQL | +| GORM | 全功能 ORM,自动迁移、关联预加载 | 快速开发、不想写 SQL | +| Ent | Facebook 出品,类型安全的 ORM | 大型项目、强类型需求 | +| database/sql + lib/pq | 标准接口,但 lib/pq 已停维护 | 简单场景 | + +项目规模不大(4 张表),手写 SQL 更可控,避免 ORM 的抽象泄漏和性能黑盒。pgx 原生支持 `pgxpool` 连接池,无需额外引入。 + +### 数据库迁移:手写 SQL + +| 方案 | 特点 | 适用场景 | +|------|------|---------| +| **手写 SQL** | 零依赖,完全可控 | 表少(<10 张)、团队小 | +| golang-migrate | CLI + 库双模式,支持版本回滚 | 表多、需要严格版本管理 | +| Atlas | 声明式迁移,HCL 定义 schema | 大型项目、多环境管理 | + +MVP 阶段 4 张表,手写 `schema.sql` 即可。后续表结构复杂后可引入 golang-migrate。 + +### 前端 Token 存储 + +| 方案 | 特点 | 选择理由 | +|------|------|---------| +| **localStorage** | 持久化存储,刷新不丢失,JS 可直接读写 | 简单直接,SPA 应用标准做法 | +| httpOnly Cookie | 防 XSS 读取,但需防 CSRF | 传统 Web 应用,需额外 CSRF 防护 | +| sessionStorage | 仅当前标签页有效 | 关闭标签页需重新登录,体验差 | + +JWT 存 localStorage,配合请求拦截器统一附加 `Authorization: Bearer ` header。refresh_token 同样存 localStorage,401 时自动触发刷新流程。 diff --git a/docs/11-持久化与用户系统设计.md b/docs/11-持久化与用户系统设计.md new file mode 100644 index 0000000..0cce137 --- /dev/null +++ b/docs/11-持久化与用户系统设计.md @@ -0,0 +1,750 @@ +# 持久化与用户系统设计 + +## 概述 + +本文档定义用户注册/登录、JWT 认证、对话历史持久化的完整设计方案。核心目标:**用户登录后可在对话列表中选择历史对话继续交谈**。 + +### 设计决策 + +| 决策项 | 选择 | 理由 | +|--------|------|------| +| 认证方式 | JWT(access + refresh 双 token) | 无状态,适合分布式部署 | +| 注册方式 | 用户名 + 密码 | MVP 最简方案 | +| 密码存储 | bcrypt hash | 行业标准,抗彩虹表 | +| 对话恢复 | 对话列表选择 | 用户可见所有历史对话,自主选择继续或新建 | +| 对话标题 | 自动取首条用户消息前 20 字符 | 零成本,自然可读 | +| 图像持久化 | 不存储 | 节省空间,文字历史已足够 | +| 登录后行为 | 先选对话,再进聊天 | 明确的入口,避免困惑 | +| WS 认证 | URL query 参数 `?token=xxx` | HTTP Upgrade 无法带 Authorization header | +| Token 策略 | access 15min + refresh 7day | 安全性与体验平衡 | + +--- + +## 一、数据库设计 + +### 1.1 ER 关系 + +``` +users 1──N sessions 1──N messages + │ + └── refresh_tokens (1──N, token 轮转管理) +``` + +### 1.2 表结构 + +```sql +-- 用户表 +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(256) NOT NULL, -- bcrypt hash + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_users_username ON users(username); + +-- 会话(对话)表 +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(128) DEFAULT '新对话', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id, updated_at DESC); + +-- 消息表 +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + role VARCHAR(16) NOT NULL, -- "user" | "assistant" + content TEXT NOT NULL, + tokens_used INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_messages_session_id ON messages(session_id, id); + +-- 刷新令牌表 +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(256) NOT NULL UNIQUE, -- SHA256(refresh_token) + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash); +``` + +### 1.3 与现有设计的差异 + +| 变更 | 原设计(`02-系统架构.md`) | 新设计 | 理由 | +|------|--------------------------|--------|------| +| `sessions.user_id` | `NOT NULL` 无外键 | `REFERENCES users(id) ON DELETE CASCADE` | 关联用户,级联删除 | +| `sessions.title` | 无 | `VARCHAR(128) DEFAULT '新对话'` | 对话列表展示 | +| `messages.image_url` | 有 | 移除 | 不存储图像 | +| `usage_daily` | 有 | MVP 暂不实现 | 按需后加 | +| 新增 `users` | 无 | 新增 | 用户系统核心 | +| 新增 `refresh_tokens` | 无 | 新增 | JWT refresh 机制 | + +--- + +## 二、JWT 认证设计 + +### 2.1 Token 结构 + +**access_token**: +- payload: `{user_id, username, exp (15min), iat, iss: "camtalk"}` +- 签名算法: HS256(对称密钥,从配置读取) +- 存储位置: 前端 localStorage + +**refresh_token**: +- payload: `{user_id, token_id (UUID), exp (7day), iat, iss: "camtalk"}` +- 存储位置: 前端 localStorage + 数据库 `refresh_tokens` 表(存 SHA256 hash) + +### 2.2 认证流程 + +#### 注册 + +``` +用户 ──POST /api/auth/register──> 检查 username 唯一性 + bcrypt hash 密码 + INSERT users + ↓ + 生成 access_token + refresh_token + 存 SHA256(refresh_token) 到 DB + ↓ + 返回 {user, access_token, refresh_token} +``` + +#### 登录 + +``` +用户 ──POST /api/auth/login──> 查 users 表 by username + bcrypt.CompareHashAndPassword + ↓ + 生成 access_token + refresh_token + 存 SHA256(refresh_token) 到 DB + ↓ + 返回 {user, access_token, refresh_token} +``` + +#### 刷新 + +``` +用户 ──POST /api/auth/refresh──> 校验 refresh_token 签名和过期 + 查 DB 验证 hash 存在 + ↓ + 撤销旧 refresh_token(DELETE) + 生成新的 access + refresh + 存新 refresh_token hash + ↓ + 返回 {access_token, refresh_token} +``` + +#### 登出 + +``` +用户 ──POST /api/auth/logout──> 撤销 refresh_token (DELETE from DB) + 前端清除 localStorage +``` + +### 2.3 Go 实现接口 + +```go +// internal/auth/jwt.go + +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +type TokenManager struct { + secret []byte + accessTTL time.Duration // 15min + refreshTTL time.Duration // 7day +} + +// GeneratePair 生成 access + refresh token 对。 +func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) + +// ValidateAccess 校验 access_token,返回 Claims。 +func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) + +// ValidateRefresh 校验 refresh_token 签名和过期(不查 DB,DB 校验由 service 层负责)。 +func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) + +// HashToken 计算 token 的 SHA256 hash(用于 DB 存储)。 +func HashToken(token string) string +``` + +```go +// internal/auth/middleware.go + +// AuthMiddleware Gin 中间件:从 Authorization: Bearer 提取并校验。 +// 校验通过后将 Claims 写入 gin.Context。 +func AuthMiddleware(tm *TokenManager) gin.HandlerFunc { + return func(c *gin.Context) { + auth := c.GetHeader("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + c.AbortWithStatusJSON(401, gin.H{"error": "missing token"}) + return + } + claims, err := tm.ValidateAccess(strings.TrimPrefix(auth, "Bearer ")) + if err != nil { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) + return + } + c.Set("claims", claims) + c.Set("user_id", claims.UserID) + c.Next() + } +} +``` + +--- + +## 三、REST API 设计 + +### 3.1 认证 API(新增) + +#### 注册 + +``` +POST /api/auth/register +Content-Type: application/json + +{"username": "alice", "password": "s3cret123"} +``` + +响应: + +```json +// 201 Created +{ + "user": {"id": "uuid", "username": "alice", "created_at": "2026-06-14T10:00:00Z"}, + "access_token": "eyJ...", + "refresh_token": "eyJ..." +} +``` + +错误码:`USERNAME_TAKEN`(409)、`INVALID_INPUT`(400,用户名/密码格式不合规) + +#### 登录 + +``` +POST /api/auth/login +Content-Type: application/json + +{"username": "alice", "password": "s3cret123"} +``` + +响应: + +```json +// 200 OK +{ + "user": {"id": "uuid", "username": "alice"}, + "access_token": "eyJ...", + "refresh_token": "eyJ..." +} +``` + +错误码:`INVALID_CREDENTIALS`(401) + +#### 刷新 Token + +``` +POST /api/auth/refresh +Content-Type: application/json + +{"refresh_token": "eyJ..."} +``` + +响应: + +```json +// 200 OK +{ + "access_token": "eyJ...", + "refresh_token": "eyJ..." +} +``` + +错误码:`INVALID_TOKEN`(401) + +#### 登出 + +``` +POST /api/auth/logout +Authorization: Bearer +Content-Type: application/json + +{"refresh_token": "eyJ..."} +``` + +响应:`204 No Content` + +### 3.2 对话管理 API(新增) + +所有端点需要 `Authorization: Bearer ` header。 + +#### 获取对话列表 + +``` +GET /api/conversations?page=1&size=20 +``` + +响应: + +```json +// 200 OK +{ + "conversations": [ + { + "id": "uuid", + "title": "这是一朵红色的玫瑰花", + "last_message": "它看起来很美丽。", + "message_count": 6, + "updated_at": "2026-06-14T10:30:00Z" + } + ], + "total": 42, + "page": 1, + "size": 20 +} +``` + +#### 创建新对话 + +``` +POST /api/conversations +Content-Type: application/json + +{} +``` + +响应: + +```json +// 201 Created +{ + "id": "uuid", + "title": "新对话", + "created_at": "2026-06-14T10:00:00Z" +} +``` + +#### 获取对话详情 + +``` +GET /api/conversations/:id +``` + +响应: + +```json +// 200 OK +{ + "id": "uuid", + "title": "这是一朵红色的玫瑰花", + "created_at": "2026-06-14T10:00:00Z", + "updated_at": "2026-06-14T10:30:00Z", + "config": {"tts_enabled": true, "detail_level": "low", "language": "zh-CN"} +} +``` + +#### 更新对话标题 + +``` +PATCH /api/conversations/:id +Content-Type: application/json + +{"title": "新的标题"} +``` + +响应:`200 OK` + 更新后的对话详情 + +#### 删除对话 + +``` +DELETE /api/conversations/:id +``` + +响应:`204 No Content`(级联删除 messages) + +#### 获取对话历史消息 + +``` +GET /api/conversations/:id/messages?limit=50&before= +``` + +响应: + +```json +// 200 OK +{ + "messages": [ + {"id": 1, "role": "user", "content": "这是什么花?", "created_at": "..."}, + {"id": 2, "role": "assistant", "content": "这是一朵红色的玫瑰。", "tokens_used": 42, "created_at": "..."} + ], + "has_more": false +} +``` + +### 3.3 现有 API 变更 + +| 端点 | 变更 | +|------|------| +| `GET /api/health` | 不变 | +| `POST /api/sessions` | **废弃**,使用 `POST /api/conversations` 替代 | +| `DELETE /api/sessions/{id}` | **废弃**,使用 `DELETE /api/conversations/:id` 替代 | + +### 3.4 新增错误码 + +| 错误码 | HTTP 状态 | 含义 | +|--------|-----------|------| +| `USERNAME_TAKEN` | 409 | 用户名已被注册 | +| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | +| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | +| `INVALID_INPUT` | 400 | 请求参数不合规(用户名/密码长度等) | + +--- + +## 四、Session Manager 改造 + +### 4.1 接口扩展 + +```go +// internal/session/manager.go + +type Manager interface { + // ===== 原有方法(签名变更) ===== + + // Create 创建新会话,关联 user_id。 + Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) + + Get(ctx context.Context, sessionID string) (*models.Session, error) + UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error + GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) + AppendMessage(ctx context.Context, sessionID string, msg models.Message) error + SetActiveRequest(ctx context.Context, sessionID string, requestID string) error + GetActiveRequestID(ctx context.Context, sessionID string) (string, error) + ClearActiveRequest(ctx context.Context, sessionID string) error + Touch(ctx context.Context, sessionID string) error + Destroy(ctx context.Context, sessionID string) error + ActiveCount() int + + // ===== 新增方法 ===== + + // ListByUser 获取用户的对话列表(分页)。 + ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) + + // UpdateTitle 更新对话标题。 + UpdateTitle(ctx context.Context, sessionID string, title string) error + + // LoadFromDB 从 PostgreSQL 加载历史消息到热存储(Redis/内存)。 + // 用户选择历史对话继续交谈时调用。 + LoadFromDB(ctx context.Context, sessionID string) error +} + +// ConversationSummary 对话列表项。 +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + LastMessage string `json:"last_message"` + MessageCount int `json:"message_count"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +### 4.2 Model 变更 + +```go +// internal/models/models.go + +type Session struct { + ID string `json:"session_id"` + UserID string `json:"user_id"` // 新增 + Title string `json:"title"` // 新增 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` // 新增 + Config SessionConfig `json:"config"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` // 不序列化到 JSON + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +### 4.3 冷热数据策略 + +``` +当前活跃会话: Redis/内存(热) ←→ PostgreSQL(冷,write-through) +历史会话加载: PostgreSQL → Redis/内存(按需恢复) +``` + +**Write-through 保证持久化**:每次 `AppendMessage` 同时写入 PostgreSQL,确保服务重启不丢数据。 + +**历史对话恢复流程**: +1. 用户从对话列表选择一个历史对话 +2. 前端带 `conversation_id` 建立 WebSocket 连接 +3. 后端调用 `sessionManager.LoadFromDB(conversationID)` 将历史消息从 PostgreSQL 加载到 Redis/内存 +4. 后续对话正常走热存储路径 + +--- + +## 五、WebSocket 认证集成 + +### 5.1 连接流程 + +``` +前端 后端 + | | + |-- WS /ws?token= ---->| + | &conversation_id= | + | |-- 校验 access_token + | |-- 校验 conversation_id 归属 + | |-- LoadFromDB(如果是历史对话) + | |-- 创建新 session(如果 conversation_id 为空) + |<-- connected {session_id} ---| + | | + |-- query {image, audio} ----->| (正常对话流程) +``` + +### 5.2 Go 实现 + +```go +// internal/ws/handler.go + +func (h *Handler) HandleWS(c *gin.Context) { + // 1. 提取并校验 access_token + tokenStr := c.Query("token") + if tokenStr == "" { + c.JSON(401, gin.H{"error": "missing token"}) + return + } + claims, err := h.tokenManager.ValidateAccess(tokenStr) + if err != nil { + c.JSON(401, gin.H{"error": "invalid token"}) + return + } + + // 2. 提取 conversation_id(可选) + conversationID := c.Query("conversation_id") + + // 3. 升级 WebSocket + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return + } + + // 4. 获取或创建 session + var sessionID string + if conversationID != "" { + // 验证该对话属于当前用户 + sess, err := h.sessionMgr.Get(c, conversationID) + if err != nil || sess.UserID != claims.UserID { + conn.WriteJSON(models.WsError{Type: "error", Code: "SESSION_NOT_FOUND"}) + conn.Close() + return + } + // 加载历史到热存储 + h.sessionMgr.LoadFromDB(c, conversationID) + sessionID = conversationID + } else { + // 创建新对话 + sessionID, _ = h.sessionMgr.Create(c, claims.UserID, models.DefaultConfig()) + } + + // 5. 进入正常 WS 处理循环 + h.handleSession(conn, sessionID, claims.UserID) +} +``` + +### 5.3 前端连接方式 + +```typescript +// WebSocket 连接 +const ws = new WebSocket( + `wss://${window.location.host}/ws?token=${accessToken}&conversation_id=${selectedConvId || ''}` +); +``` + +--- + +## 六、前端设计概要 + +### 6.1 页面路由 + +``` +/ → 未登录重定向到 /login +/login → AuthPage(登录/注册表单) +/chat → 主界面(需登录) +/chat/:id → 主界面,自动加载指定对话 +``` + +### 6.2 组件结构 + +``` +App +├── AuthPage ← 新增:登录/注册 +└── ChatLayout(需登录) + ├── ConversationList ← 新增:侧边栏对话列表 + │ ├── 对话项(标题、最后消息、时间) + │ ├── 新建对话按钮 + │ └── 删除对话按钮 + ├── ChatPanel ← 现有,需适配多对话 + ├── VideoPreview ← 现有 + ├── MicManager ← 现有 + └── ConfigPanel ← 现有 +``` + +### 6.3 新增 Hook + +```typescript +// useAuth — 认证状态管理 +function useAuth() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const login = async (username: string, password: string) => { ... }; + const register = async (username: string, password: string) => { ... }; + const logout = async () => { ... }; + const refreshToken = async () => { ... }; + + // 请求拦截器:自动附加 Authorization header + // 401 时自动尝试 refresh,失败则跳转登录 + + return { user, loading, login, register, logout }; +} + +// useConversations — 对话列表管理 +function useConversations() { + const [conversations, setConversations] = useState([]); + const [currentId, setCurrentId] = useState(null); + + const fetchList = async (page?: number) => { ... }; + const createNew = async () => { ... }; + const deleteConv = async (id: string) => { ... }; + const renameConv = async (id: string, title: string) => { ... }; + const selectConv = (id: string) => { setCurrentId(id); }; + + return { conversations, currentId, fetchList, createNew, deleteConv, renameConv, selectConv }; +} +``` + +### 6.4 对话标题自动生成 + +```go +// 内部逻辑:首条 user 消息的前 20 个字符作为 title +func generateTitle(firstMessage string) string { + runes := []rune(firstMessage) + if len(runes) > 20 { + return string(runes[:20]) + "…" + } + return firstMessage +} +``` + +在 `AppendMessage` 时,如果 session 的 title 仍为 "新对话",自动更新为 `generateTitle(msg.Content)`。 + +--- + +## 七、配置扩展 + +### 7.1 Go 配置结构体 + +```go +type Config struct { + App AppConfig `mapstructure:"app"` + Server ServerConfig `mapstructure:"server"` + Auth AuthConfig `mapstructure:"auth"` // 新增 + Redis RedisConfig `mapstructure:"redis"` + AI AIConfig `mapstructure:"ai"` + Storage StorageConfig `mapstructure:"storage"` + Log LogConfig `mapstructure:"log"` +} + +type AuthConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` // 必须通过环境变量设置 + AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15 + RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 (7天) +} +``` + +### 7.2 配置文件示例 + +```yaml +# config.yaml +auth: + access_ttl: 15 # 分钟 + refresh_ttl: 10080 # 7天 + +storage: + driver: "memory" # "memory" | "postgres" + dsn: "" +``` + +### 7.3 环境变量 + +| 配置项 | 环境变量 | 说明 | +|--------|---------|------| +| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | **必须设置**,JWT 签名密钥 | +| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) | +| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) | +| `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `"memory"` 或 `"postgres"` | +| `storage.dsn` | `CAMTALK_STORAGE_DSN` | PostgreSQL 连接串 | + +--- + +## 八、实施阶段 + +### Phase 1:用户认证系统 + +- [ ] 数据库 schema 迁移脚本(users, refresh_tokens 表) +- [ ] `internal/auth/` 包:TokenManager, bcrypt 工具, JWT 中间件 +- [ ] `internal/store/user.go`:UserRepository 接口 + PostgreSQL 实现 +- [ ] REST API:`/api/auth/register`, `/api/auth/login`, `/api/auth/refresh`, `/api/auth/logout` +- [ ] 单元测试 + +### Phase 2:对话 CRUD + 消息持久化 + +- [ ] 数据库 schema 迁移脚本(sessions, messages 表改造) +- [ ] `internal/store/conversation.go`:ConversationRepository 接口 + PostgreSQL 实现 +- [ ] Session Manager 扩展:Create 绑定 user_id, ListByUser, UpdateTitle +- [ ] REST API:`/api/conversations` CRUD + `/api/conversations/:id/messages` +- [ ] Write-through:AppendMessage 同时写 PostgreSQL + +### Phase 3:对话历史恢复 + +- [ ] `sessionManager.LoadFromDB()` 实现 +- [ ] 对话标题自动生成逻辑 +- [ ] REST API:对话详情、历史消息查询(分页) + +### Phase 4:前端集成 + +- [ ] `useAuth` hook + 请求拦截器(自动附加 token、自动 refresh) +- [ ] `AuthPage` 组件(登录/注册表单) +- [ ] `ConversationList` 组件 +- [ ] `useConversations` hook +- [ ] 路由守卫:未登录重定向到 `/login` +- [ ] WebSocket 连接带 token + conversation_id +- [ ] `useVisionSession` 适配多对话切换 + +### Phase 5:配置与收尾 + +- [ ] 配置结构体扩展(AuthConfig) +- [ ] config.yaml 更新 +- [ ] docker-compose 添加 PostgreSQL +- [ ] 集成测试 +- [ ] 更新 `02-系统架构.md` 和 `03-接口文档.md` diff --git a/docs/PLAN_USER_MODULE.md b/docs/PLAN_USER_MODULE.md new file mode 100644 index 0000000..2bc764e --- /dev/null +++ b/docs/PLAN_USER_MODULE.md @@ -0,0 +1,797 @@ +# CamTalk 后端用户模块构建计划 + +## Context + +后端 AI 管道(STT → LLM → TTS)已完成,现在需要实现用户系统和对话持久化。目标:**用户注册登录后,可在对话列表中选择历史对话继续交谈**。 + +设计文档:`docs/11-持久化与用户系统设计.md`(最高依据) +技术选型:`docs/04-技术选型.md` 第四章 +现有后端计划:`docs/PLAN_BACKEND.md`(AI 管道部分已完成) + +### 当前后端状态 + +| 模块 | 状态 | +|------|------| +| config | ✅ 已完成,需扩展 AuthConfig | +| logger | ✅ 已完成 | +| errors | ✅ 已完成,需扩展用户相关错误码 | +| models | ✅ 已完成,需扩展 User/Session | +| session manager | ✅ 内存/Redis 已完成,需扩展 user_id 绑定 | +| AI 服务层 | ✅ STT/LLM/TTS 已完成 | +| orchestrator | ✅ 已完成 | +| ws handler | ✅ 已完成,需接入 JWT 认证 | +| REST API | ✅ sessions CRUD 已完成,需新增 auth + conversations | + +### 新增依赖 + +| 包 | 用途 | 引入阶段 | +|----|------|---------| +| `github.com/golang-jwt/jwt/v5` | JWT 签发/校验 | Phase 1 | +| `golang.org/x/crypto/bcrypt` | 密码哈希 | Phase 1 | +| `github.com/jackc/pgx/v5` | PostgreSQL 驱动 | Phase 2 | + +--- + +## 分阶段实施 + +### Phase 1:配置扩展 + 数据库连接 + +**目标**:扩展配置结构体,建立 PostgreSQL 连接池。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 1.1 | 扩展 Config 结构体 | `internal/config/config.go` | 新增 `AuthConfig`(JWTSecret, AccessTTL, RefreshTTL),`StorageConfig` 已有 Driver/DSN 字段 | +| 1.2 | 添加配置默认值 | `internal/config/config.go` | `auth.access_ttl` 默认 15,`auth.refresh_ttl` 默认 10080 | +| 1.3 | 实现数据库连接池 | `internal/store/db.go` | `NewPostgresPool(ctx, dsn) (*pgxpool.Pool, error)`,启动时调用,注入到各 repository | +| 1.4 | 编写 schema 迁移脚本 | `migrations/001_users.up.sql` | `users` 表 + `refresh_tokens` 表 | +| 1.5 | 编写回滚脚本 | `migrations/001_users.down.sql` | DROP TABLE | +| 1.6 | main.go 条件初始化 DB | `cmd/server/main.go` | `storage.driver == "postgres"` 时创建 pgxpool,否则跳过(纯内存模式) | + +**配置扩展示例**: + +```go +// internal/config/config.go 新增 + +type AuthConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置 + AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15 + RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080 +} +``` + +**数据库连接**: + +```go +// internal/store/db.go + +package store + +import ( + "context" + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 10 + return pgxpool.NewWithConfig(ctx, cfg) +} +``` + +--- + +### Phase 2:用户模型 + Repository + +**目标**:定义用户数据模型和持久化接口。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 2.1 | 扩展 models | `internal/models/models.go` | 新增 `User` 结构体(ID, Username, PasswordHash, CreatedAt, UpdatedAt) | +| 2.2 | 定义 UserRepository 接口 | `internal/store/user.go` | `Create`, `FindByUsername`, `FindByID`, `SaveRefreshToken`, `FindRefreshToken`, `DeleteRefreshToken` | +| 2.3 | 实现 PostgreSQL UserRepository | `internal/store/user_pg.go` | pgx 实现,所有方法使用 `pgxpool.Pool` | +| 2.4 | 实现内存 UserRepository(测试用) | `internal/store/user_mem.go` | `sync.RWMutex` + map,单元测试时注入 | +| 2.5 | 编写 UserRepository 测试 | `internal/store/user_pg_test.go` | 需要测试 DB 或 mock | + +**UserRepository 接口**: + +```go +// internal/store/user.go + +package store + +import ( + "context" + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username already taken") + ErrRefreshTokenNotFound = errors.New("refresh token not found") +) + +type UserRepository interface { + // Create 创建用户,返回生成的 ID。 + Create(ctx context.Context, username, passwordHash string) (string, error) + + // FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。 + FindByUsername(ctx context.Context, username string) (*User, error) + + // FindByID 按 ID 查找,不存在返回 ErrUserNotFound。 + FindByID(ctx context.Context, id string) (*User, error) + + // SaveRefreshToken 保存 refresh token hash。 + SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + + // FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。 + FindRefreshToken(ctx context.Context, tokenHash string) (string, error) + + // DeleteRefreshToken 按 token hash 删除。 + DeleteRefreshToken(ctx context.Context, tokenHash string) error + + // DeleteUserRefreshTokens 删除用户的所有 refresh token(登出所有设备)。 + DeleteUserRefreshTokens(ctx context.Context, userID string) error +} + +// User 用户数据模型(store 层)。 +type User struct { + ID string + Username string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +--- + +### Phase 3:JWT + 认证服务 + +**目标**:实现 JWT 签发/校验、bcrypt 密码处理、认证业务逻辑。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 3.1 | 实现 TokenManager | `internal/auth/jwt.go` | `GeneratePair`, `ValidateAccess`, `ValidateRefresh`, `HashToken` | +| 3.2 | 实现密码工具 | `internal/auth/password.go` | `HashPassword(password) (string, error)`, `CheckPassword(hash, password) error` | +| 3.3 | 实现 AuthMiddleware | `internal/auth/middleware.go` | Gin 中间件,从 `Authorization: Bearer ` 提取 Claims 写入 Context | +| 3.4 | 定义 AuthService 接口 | `internal/auth/service.go` | 业务层封装:`Register`, `Login`, `Refresh`, `Logout` | +| 3.5 | 实现 AuthService | `internal/auth/service.go` | 组合 TokenManager + UserRepository | +| 3.6 | 编写 TokenManager 测试 | `internal/auth/jwt_test.go` | 生成/校验/过期/hash | +| 3.7 | 编写 AuthService 测试 | `internal/auth/service_test.go` | mock UserRepository,覆盖注册重复、密码错误、token 轮转 | + +**TokenManager 核心实现**: + +```go +// internal/auth/jwt.go + +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +type TokenManager struct { + secret []byte + accessTTL time.Duration + refreshTTL time.Duration +} + +func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager { + return &TokenManager{ + secret: []byte(secret), + accessTTL: accessTTL, + refreshTTL: refreshTTL, + } +} + +func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) { + // access_token: 15min + accessClaims := &Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.accessTTL)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "camtalk", + }, + } + accessTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + access, err = accessTkn.SignedString(tm.secret) + if err != nil { + return "", "", err + } + + // refresh_token: 7day, 含唯一 token_id + tokenID := uuid.New().String() + refreshClaims := &Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ID: tokenID, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(tm.refreshTTL)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "camtalk", + }, + } + refreshTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refresh, err = refreshTkn.SignedString(tm.secret) + return +} + +func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) { + return tm.validate(tokenStr) +} + +func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) { + return tm.validate(tokenStr) +} + +func (tm *TokenManager) validate(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + return tm.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, jwt.ErrTokenInvalidClaims + } + return claims, nil +} + +// HashToken SHA256 hash,用于 DB 存储。 +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} +``` + +**AuthService 接口**: + +```go +// internal/auth/service.go + +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +type AuthResponse struct { + User UserResponse `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type Service interface { + Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) + Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) + Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) + Logout(ctx context.Context, userID, refreshToken string) error +} +``` + +--- + +### Phase 4:认证 REST API + +**目标**:实现注册、登录、刷新、登出四个端点。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 4.1 | 实现 AuthHandler | `internal/api/auth.go` | `Register`, `Login`, `Refresh`, `Logout` 处理函数 | +| 4.2 | 输入校验 | `internal/api/auth.go` | 用户名 3-64 字符,密码 8-72 字符 | +| 4.3 | 注册路由 | `internal/api/auth.go` | `RegisterRoutes(rg *gin.RouterGroup)` | +| 4.4 | main.go 接入 | `cmd/server/main.go` | 创建 TokenManager + AuthService + AuthHandler,注册路由 | +| 4.5 | 编写 API 测试 | `internal/api/auth_test.go` | httptest + mock AuthService | + +**AuthHandler 结构**: + +```go +// internal/api/auth.go + +type AuthHandler struct { + authService auth.Service +} + +func NewAuthHandler(authService auth.Service) *AuthHandler { + return &AuthHandler{authService: authService} +} + +func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) { + auth := rg.Group("/auth") + { + auth.POST("/register", h.Register) + auth.POST("/login", h.Login) + auth.POST("/refresh", h.Refresh) + auth.POST("/logout", auth.AuthMiddleware(), h.Logout) + } +} +``` + +**错误响应格式**(统一现有风格): + +```json +{ + "code": "USERNAME_TAKEN", + "message": "username already taken" +} +``` + +新增错误码到 `internal/errors/codes.go`: + +```go +const ( + CodeUsernameTaken = "USERNAME_TAKEN" + CodeInvalidCredentials = "INVALID_CREDENTIALS" + CodeInvalidToken = "INVALID_TOKEN" + CodeInvalidInput = "INVALID_INPUT" +) +``` + +--- + +### Phase 5:Session Manager 改造 + +**目标**:Session Manager 关联 user_id,支持对话列表查询。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 5.1 | 扩展 Session 模型 | `internal/models/models.go` | `Session` 新增 `UserID`, `Title`, `UpdatedAt` 字段 | +| 5.2 | 扩展 Manager 接口 | `internal/session/manager.go` | `Create` 签名加 `userID`;新增 `ListByUser`, `UpdateTitle`;新增 `ConversationSummary` 类型 | +| 5.3 | 修改 MemoryManager | `internal/session/memory.go` | `Create` 存储 userID;实现 `ListByUser`(遍历+过滤+排序);实现 `UpdateTitle` | +| 5.4 | 修改 RedisManager | `internal/session/redis.go` | `session:{id}:meta` 新增 `user_id`、`title` 字段;`ListByUser` 使用 Redis Set `user:{id}:sessions` 索引 | +| 5.5 | 编写新方法测试 | `internal/session/memory_test.go` | 覆盖 ListByUser 分页、UpdateTitle、Create 带 userID | +| 5.6 | 更新 ws handler 调用 | `internal/ws/handler.go` | `sessionMgr.Create` 调用传入 userID(此时 Phase 7 才有真实 userID,先用空字符串兼容) | + +**接口变更**: + +```go +// internal/session/manager.go 变更 + +type Manager interface { + // Create 签名变更:新增 userID 参数 + Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) + + // 新增方法 + ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) + UpdateTitle(ctx context.Context, sessionID string, title string) error + + // 其余方法不变... +} + +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + LastMessage string `json:"last_message"` + MessageCount int `json:"message_count"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +**MemoryManager ListByUser 实现思路**: + +```go +func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + // 收集该用户的所有 session + var list []ConversationSummary + for _, entry := range m.sessions { + if entry.session.UserID != userID { + continue + } + summary := ConversationSummary{ + ID: entry.session.ID, + Title: entry.session.Title, + MessageCount: len(entry.history), + UpdatedAt: entry.lastActive, + } + if len(entry.history) > 0 { + summary.LastMessage = entry.history[len(entry.history)-1].Content + } + list = append(list, summary) + } + + // 按 UpdatedAt 降序排序 + sort.Slice(list, func(i, j int) bool { + return list[i].UpdatedAt.After(list[j].UpdatedAt) + }) + + total := len(list) + + // 分页 + start := (page - 1) * size + if start >= total { + return []ConversationSummary{}, total, nil + } + end := start + size + if end > total { + end = total + } + + return list[start:end], total, nil +} +``` + +--- + +### Phase 6:对话 REST API + +**目标**:实现对话 CRUD 和历史消息查询端点。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 6.1 | 实现 ConversationHandler | `internal/api/conversation.go` | `List`, `Create`, `Get`, `UpdateTitle`, `Delete`, `GetMessages` | +| 6.2 | 权限校验 | `internal/api/conversation.go` | 每个端点校验 session.UserID == claims.UserID | +| 6.3 | 注册路由 | `internal/api/conversation.go` | `RegisterRoutes(rg *gin.RouterGroup)`,全部走 AuthMiddleware | +| 6.4 | main.go 接入 | `cmd/server/main.go` | 创建 ConversationHandler 并注册 | +| 6.5 | 编写 API 测试 | `internal/api/conversation_test.go` | httptest + mock SessionManager | + +**ConversationHandler 结构**: + +```go +// internal/api/conversation.go + +type ConversationHandler struct { + sessionMgr session.Manager +} + +func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) { + conv := rg.Group("/conversations", auth.AuthMiddleware(tokenMgr)) + { + conv.GET("", h.List) + conv.POST("", h.Create) + conv.GET("/:id", h.Get) + conv.PATCH("/:id", h.UpdateTitle) + conv.DELETE("/:id", h.Delete) + conv.GET("/:id/messages", h.GetMessages) + } +} +``` + +**权限校验模式**(每个端点复用): + +```go +func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) { + sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID) + if err != nil { + return nil, err + } + userID := c.GetString("user_id") // 从 AuthMiddleware 写入 + if sess.UserID != userID { + return nil, session.ErrSessionNotFound // 返回 404 而非 403,避免信息泄露 + } + return sess, nil +} +``` + +**GetMessages 实现要点**: +- 从 Session Manager 的 `GetHistory` 获取消息 +- 支持 `?limit=50&before=` 分页 +- 内存实现中,history 是全量存储的,直接按索引切片即可 + +--- + +### Phase 7:WebSocket 认证集成 + +**目标**:WS 连接需要 JWT 认证,支持指定 conversation_id 恢复历史对话。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 7.1 | 修改 ServeWS 签名 | `internal/ws/handler.go` | 新增 `tokenMgr *auth.TokenManager` 参数 | +| 7.2 | WS 连接认证 | `internal/ws/handler.go` | 从 `?token=xxx` 提取并校验 access_token,失败返回 401 | +| 7.3 | conversation_id 处理 | `internal/ws/handler.go` | `?conversation_id=xxx` 存在时:校验归属 → LoadFromDB → 复用 session;否则创建新 session | +| 7.4 | AppendMessage 自动标题 | `internal/session/memory.go` | 首条 user 消息时,如果 title == "新对话",自动更新为前 20 字符 | +| 7.5 | main.go 更新 | `cmd/server/main.go` | 传入 tokenMgr 到 ServeWS | +| 7.7 | 编写认证测试 | `internal/ws/handler_test.go` | 测试无 token / 过期 token / 有效 token / conversation_id 恢复 | + +**WS 连接流程变更**: + +``` +客户端请求: GET /ws?token=&conversation_id= + +服务端处理: + 1. token 为空 → 401 {"error": "missing token"} + 2. token 无效/过期 → 401 {"error": "invalid token"} + 3. conversation_id 非空: + a. session 不存在或 user_id 不匹配 → 401 {"error": "SESSION_NOT_FOUND"} + b. sessionMgr.LoadFromDB(conversationID) → 加载历史到热存储 + c. sessionID = conversationID + 4. conversation_id 为空: + a. sessionMgr.Create(userID, defaultConfig) → 创建新 session + 5. Upgrade WebSocket → 发送 connected 消息 +``` + +**对话标题自动生成**: + +```go +// internal/session/memory.go — AppendMessage 中追加逻辑 + +func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + + entry, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + + entry.history = append(entry.history, msg) + entry.lastActive = time.Now() + + // 自动更新标题 + if msg.Role == "user" && entry.session.Title == "新对话" { + entry.session.Title = generateTitle(msg.Content) + } + + // 限制历史上限 + if len(entry.history) > m.maxHistory { + entry.history = entry.history[len(entry.history)-m.maxHistory:] + } + + return nil +} + +func generateTitle(firstMessage string) string { + runes := []rune(firstMessage) + if len(runes) > 20 { + return string(runes[:20]) + "…" + } + return firstMessage +} +``` + +--- + +### Phase 8:消息持久化(Write-Through) + +**目标**:对话消息同时写入 PostgreSQL,保证重启不丢数据。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 8.1 | 定义 MessageRepository 接口 | `internal/store/message.go` | `SaveMessage`, `GetMessages`, `GetLastMessage` | +| 8.2 | 实现 PostgreSQL MessageRepository | `internal/store/message_pg.go` | pgx 实现 | +| 8.3 | Session Manager 注入 MessageRepository | `internal/session/memory.go` | AppendMessage 时同时调用 repo.SaveMessage(write-through) | +| 8.4 | LoadFromDB 实现 | `internal/session/memory.go` | 从 PostgreSQL 读取消息加载到内存 history | +| 8.5 | ConversationSummary 查询优化 | `internal/store/message_pg.go` | 对话列表的 last_message 和 message_count 通过 SQL 聚合查询 | + +**MessageRepository 接口**: + +```go +// internal/store/message.go + +type MessageRepository interface { + // SaveMessage 保存一条消息。 + SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error + + // GetMessages 获取会话的消息列表(分页,按 id 升序)。 + GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) + + // GetLastMessage 获取会话的最后一条消息。 + GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) + + // GetMessageCount 获取会话的消息总数。 + GetMessageCount(ctx context.Context, sessionID string) (int, error) +} + +type StoredMessage struct { + ID int64 `json:"id"` + SessionID string `json:"-"` + Role string `json:"role"` + Content string `json:"content"` + TokensUsed int `json:"tokens_used"` + CreatedAt time.Time `json:"created_at"` +} +``` + +**Write-Through 模式**: + +```go +// internal/session/memory.go — AppendMessage 改造 + +func (m *MemoryManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error { + // 1. 写热存储(内存/Redis) + m.mu.Lock() + entry, ok := m.sessions[sessionID] + if !ok { + m.mu.Unlock() + return ErrSessionNotFound + } + entry.history = append(entry.history, msg) + entry.lastActive = time.Now() + if msg.Role == "user" && entry.session.Title == "新对话" { + entry.session.Title = generateTitle(msg.Content) + } + if len(entry.history) > m.maxHistory { + entry.history = entry.history[len(entry.history)-m.maxHistory:] + } + m.mu.Unlock() + + // 2. 写冷存储(PostgreSQL,异步不阻塞) + if m.msgRepo != nil { + go func() { + if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { + logger.Log.Warnw("persist message failed", "session", sessionID, "error", err) + } + }() + } + + return nil +} +``` + +--- + +### Phase 9:旧端点废弃 + 集成收尾 + +**目标**:废弃旧的 `/api/sessions` 端点,完成全链路集成。 + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 9.1 | 废弃旧 session 路由 | `internal/api/session.go` | 保留代码但标记 deprecated,或直接删除 | +| 9.2 | main.go 完整组装 | `cmd/server/main.go` | 按 storage.driver 选择注入 MemoryRepo 或 PgRepo | +| 9.3 | .env.example 更新 | `backend/.env.example` | 新增 `CAMTALK_AUTH_JWT_SECRET`、`CAMTALK_STORAGE_*` | +| 9.4 | config.yaml 更新 | `backend/config.yaml` | 新增 auth 配置块 | +| 9.5 | docker-compose 添加 PG | `docker-compose.yml` | PostgreSQL 15 服务 + 环境变量 | +| 9.6 | go mod tidy | `backend/` | 清理依赖 | +| 9.7 | 端到端手动测试 | — | 注册 → 登录 → 创建对话 → 发送消息 → 登出 → 重新登录 → 查看对话列表 → 选择历史对话继续 | + +**main.go 依赖注入全貌**: + +```go +func main() { + cfg, _ := config.Load() + logger.Init(cfg.Log.Level, cfg.Log.Format) + + // --- 存储层 --- + var ( + userRepo store.UserRepository + msgRepo store.MessageRepository + sessionMgr session.Manager + ) + + if cfg.Storage.Driver == "postgres" { + pool, _ := store.NewPostgresPool(ctx, cfg.Storage.DSN) + defer pool.Close() + userRepo = store.NewPgUserRepository(pool) + msgRepo = store.NewPgMessageRepository(pool) + sessionMgr = session.NewMemoryManager(..., msgRepo) // 注入 msgRepo + } else { + userRepo = store.NewMemUserRepository() + sessionMgr = session.NewMemoryManager(...) // 无 msgRepo,纯内存 + } + + // --- 认证 --- + tokenMgr := auth.NewTokenManager(cfg.Auth.JWTSecret, + time.Duration(cfg.Auth.AccessTTL)*time.Minute, + time.Duration(cfg.Auth.RefreshTTL)*time.Minute) + authService := auth.NewAuthService(tokenMgr, userRepo) + + // --- AI 服务(不变)--- + sttService := ... + llmService := ... + ttsService := ... + orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg) + + // --- 路由 --- + r := gin.New() + apiGroup := r.Group("/api") + apiGroup.GET("/health", healthHandler(sessionMgr, cfg)) + + authHandler := api.NewAuthHandler(authService) + authHandler.RegisterRoutes(apiGroup) + + convHandler := api.NewConversationHandler(sessionMgr, tokenMgr) + convHandler.RegisterRoutes(apiGroup) + + r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr)) + + // ... 启动 +} +``` + +--- + +## 关键文件清单 + +``` +backend/ + cmd/server/main.go ← Phase 1.6, 4.4, 7.5, 9.2 + migrations/ + 001_users.up.sql ← Phase 1.4(新建) + 001_users.down.sql ← Phase 1.5(新建) + internal/ + config/config.go ← Phase 1.1, 1.2(修改) + errors/codes.go ← Phase 4.2(修改,新增错误码) + models/models.go ← Phase 5.1(修改) + store/ + db.go ← Phase 1.3(新建) + user.go ← Phase 2.2(新建) + user_pg.go ← Phase 2.3(新建) + user_mem.go ← Phase 2.4(新建) + user_pg_test.go ← Phase 2.5(新建) + message.go ← Phase 8.1(新建) + message_pg.go ← Phase 8.2(新建) + auth/ + jwt.go ← Phase 3.1(新建) + password.go ← Phase 3.2(新建) + middleware.go ← Phase 3.3(新建) + service.go ← Phase 3.4, 3.5(新建) + jwt_test.go ← Phase 3.6(新建) + service_test.go ← Phase 3.7(新建) + session/ + manager.go ← Phase 5.2(修改) + memory.go ← Phase 5.3, 7.4, 8.3, 8.4(修改) + redis.go ← Phase 5.4(修改) + memory_test.go ← Phase 5.5(修改) + api/ + auth.go ← Phase 4.1, 4.3(新建) + auth_test.go ← Phase 4.5(新建) + conversation.go ← Phase 6.1, 6.2, 6.3(新建) + conversation_test.go ← Phase 6.5(新建) + session.go ← Phase 9.1(废弃/删除) + ws/ + handler.go ← Phase 7.1, 7.2, 7.3(修改) + handler_test.go ← Phase 7.7(修改) +``` + +--- + +## 执行顺序与依赖关系 + +``` +Phase 1 (配置 + DB 连接) + ↓ +Phase 2 (User 模型 + Repository) ← 依赖 Phase 1 + ↓ +Phase 3 (JWT + AuthService) ← 依赖 Phase 2 + ↓ +Phase 4 (Auth REST API) ← 依赖 Phase 3 + ↓ +Phase 5 (Session Manager 改造) ← 依赖 Phase 1(模型扩展),可与 Phase 2-4 并行 + ↓ +Phase 6 (Conversation REST API) ← 依赖 Phase 4 + 5 + ↓ +Phase 7 (WS 认证集成) ← 依赖 Phase 3 + 5 + ↓ +Phase 8 (消息持久化) ← 依赖 Phase 1 + 5 + ↓ +Phase 9 (废弃旧端点 + 集成收尾) ← 依赖全部 +``` + +**可并行的路径**: +- Phase 2-4(用户认证链路)和 Phase 5(Session 改造)可并行开发 +- Phase 6(对话 API)和 Phase 7(WS 认证)可并行开发 + +--- + +## 验证方案 + +| 层级 | 方法 | 覆盖范围 | +|------|------|---------| +| 单元测试 | `go test ./internal/auth/... ./internal/store/...` | JWT 生成/校验、密码 hash、Repository CRUD | +| API 测试 | `httptest` + `go test ./internal/api/...` | 注册/登录/刷新/登出、对话 CRUD、权限校验 | +| 集成测试 | 启动 Gin test server + WS client | WS 认证、conversation_id 恢复、消息持久化 | +| 端到端 | 手动测试 | 注册 → 登录 → 对话 → 登出 → 重登 → 历史列表 → 继续对话 | +| 静态检查 | `go vet ./...` + `go test ./...` | 全量通过 | -- 2.49.1 From 0edafbbf8a4105ff5b5ddb82a5024b5a06e345a9 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:41:50 +0800 Subject: [PATCH 08/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PostgreSQL?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 internal/store/db.go - 实现 NewPostgresPool(ctx, dsn) 创建连接池 - 添加 pgx/v5 依赖 - 最大连接数设置为 10 --- backend/go.mod | 9 +++++++-- backend/go.sum | 18 ++++++++++++++---- backend/internal/store/db.go | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 backend/internal/store/db.go diff --git a/backend/go.mod b/backend/go.mod index 8e60b15..069496b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,11 +1,12 @@ module github.com/hhs/camtalk -go 1.24 +go 1.25.0 require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 github.com/redis/go-redis/v9 v9.20.1 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 @@ -27,6 +28,9 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -50,8 +54,9 @@ require ( golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 01eeeb8..d4c2b90 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -44,6 +44,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -120,16 +128,18 @@ golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go new file mode 100644 index 0000000..bc0d6c8 --- /dev/null +++ b/backend/internal/store/db.go @@ -0,0 +1,17 @@ +package store + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// NewPostgresPool 创建 PostgreSQL 连接池。 +func NewPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + cfg.MaxConns = 10 + return pgxpool.NewWithConfig(ctx, cfg) +} -- 2.49.1 From bf1e24453c757d3acf3faad95d011583ef7a3969 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:42:12 +0800 Subject: [PATCH 09/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=A1=A8=20schema=20=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 migrations/001_users.up.sql:创建 users 表和 refresh_tokens 表 - 新增 migrations/001_users.down.sql:回滚脚本 - users 表包含 id, username, password_hash, created_at, updated_at - refresh_tokens 表包含 id, user_id, token_hash, expires_at, created_at - 添加必要的索引优化查询性能 --- backend/migrations/001_users.down.sql | 5 +++++ backend/migrations/001_users.up.sql | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 backend/migrations/001_users.down.sql create mode 100644 backend/migrations/001_users.up.sql diff --git a/backend/migrations/001_users.down.sql b/backend/migrations/001_users.down.sql new file mode 100644 index 0000000..4d30d76 --- /dev/null +++ b/backend/migrations/001_users.down.sql @@ -0,0 +1,5 @@ +-- 删除 Refresh Token 表(自动删除相关索引) +DROP TABLE IF EXISTS refresh_tokens; + +-- 删除用户表(自动删除相关索引) +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/001_users.up.sql b/backend/migrations/001_users.up.sql new file mode 100644 index 0000000..76ff405 --- /dev/null +++ b/backend/migrations/001_users.up.sql @@ -0,0 +1,26 @@ +-- 用户表 +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 用户名索引(用于登录查询) +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + +-- Refresh Token 表 +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Token hash 索引(用于刷新验证) +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); + +-- 用户 ID 索引(用于登出所有设备) +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); -- 2.49.1 From 0ff810a2970689c72fc59828fe18a4a1761c0178 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:43:07 +0800 Subject: [PATCH 10/46] =?UTF-8?q?feat:=20main.go=20=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=20PostgreSQL=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 store 包导入 - 当 storage.driver 为 postgres 时创建 pgxpool - 使用 defer 确保连接池正确关闭 - 添加日志记录连接状态 --- backend/cmd/server/main.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 829c415..8091719 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -19,6 +19,7 @@ import ( "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/orchestrator" "github.com/hhs/camtalk/internal/session" + "github.com/hhs/camtalk/internal/store" "github.com/hhs/camtalk/internal/ws" ) @@ -44,6 +45,21 @@ func main() { "addr", cfg.Server.Addr(), ) + // 初始化存储层(条件初始化 PostgreSQL) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if cfg.Storage.Driver == "postgres" { + pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN) + if err != nil { + logger.Log.Fatalw("failed to connect to postgres", "error", err) + } + defer pool.Close() + logger.Log.Infow("postgres connected", "driver", cfg.Storage.Driver) + // TODO: Phase 2 - 初始化 UserRepository 和 MessageRepository + _ = pool + } + // 初始化 Session Manager(MVP 默认内存实现) var sessionMgr session.Manager // TODO: 当 Redis 配置非空时切换为 RedisManager -- 2.49.1 From c9c174f400d85a4862dc630d2bbe85e4993e2128 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:53:54 +0800 Subject: [PATCH 11/46] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20models=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20User=20=E7=BB=93=E6=9E=84=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/models/models.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index b07924c..e83680b 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -41,6 +41,15 @@ func (p SessionConfigPatch) Apply(cfg *SessionConfig) { } } +// User 用户。 +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Message 对话消息。 type Message struct { Role string `json:"role"` // "user" | "assistant" -- 2.49.1 From af73e78aa3cd2ac68f5b87b8cb9f7e9fc3b350ef Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:54:21 +0800 Subject: [PATCH 12/46] =?UTF-8?q?feat:=20=E5=AE=9A=E4=B9=89=20UserReposito?= =?UTF-8?q?ry=20=E6=8E=A5=E5=8F=A3=E5=8F=8A=20User=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user.go | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 backend/internal/store/user.go diff --git a/backend/internal/store/user.go b/backend/internal/store/user.go new file mode 100644 index 0000000..40dcb78 --- /dev/null +++ b/backend/internal/store/user.go @@ -0,0 +1,46 @@ +package store + +import ( + "context" + "errors" + "time" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrUsernameTaken = errors.New("username already taken") + ErrRefreshTokenNotFound = errors.New("refresh token not found") +) + +// UserRepository 用户持久化接口。 +type UserRepository interface { + // Create 创建用户,返回生成的 ID。 + Create(ctx context.Context, username, passwordHash string) (string, error) + + // FindByUsername 按用户名查找,不存在返回 ErrUserNotFound。 + FindByUsername(ctx context.Context, username string) (*User, error) + + // FindByID 按 ID 查找,不存在返回 ErrUserNotFound。 + FindByID(ctx context.Context, id string) (*User, error) + + // SaveRefreshToken 保存 refresh token hash。 + SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + + // FindRefreshToken 按 token hash 查找,返回 user_id。不存在返回 ErrRefreshTokenNotFound。 + FindRefreshToken(ctx context.Context, tokenHash string) (string, error) + + // DeleteRefreshToken 按 token hash 删除。 + DeleteRefreshToken(ctx context.Context, tokenHash string) error + + // DeleteUserRefreshTokens 删除用户的所有 refresh token(登出所有设备)。 + DeleteUserRefreshTokens(ctx context.Context, userID string) error +} + +// User 用户数据模型(store 层)。 +type User struct { + ID string + Username string + PasswordHash string + CreatedAt time.Time + UpdatedAt time.Time +} -- 2.49.1 From 36827edfeac4d0924d6037b18f47672f30bf0ac9 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:55:12 +0800 Subject: [PATCH 13/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20PostgreSQL?= =?UTF-8?q?=20UserRepository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_pg.go | 101 ++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 backend/internal/store/user_pg.go diff --git a/backend/internal/store/user_pg.go b/backend/internal/store/user_pg.go new file mode 100644 index 0000000..fec0631 --- /dev/null +++ b/backend/internal/store/user_pg.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PgUserRepository 基于 PostgreSQL 的 UserRepository 实现。 +type PgUserRepository struct { + pool *pgxpool.Pool +} + +// NewPgUserRepository 创建 PgUserRepository。 +func NewPgUserRepository(pool *pgxpool.Pool) *PgUserRepository { + return &PgUserRepository{pool: pool} +} + +func (r *PgUserRepository) Create(ctx context.Context, username, passwordHash string) (string, error) { + var id string + err := r.pool.QueryRow(ctx, + `INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`, + username, passwordHash, + ).Scan(&id) + if err != nil { + return "", err + } + return id, nil +} + +func (r *PgUserRepository) FindByUsername(ctx context.Context, username string) (*User, error) { + var u User + err := r.pool.QueryRow(ctx, + `SELECT id, username, password_hash, created_at, updated_at FROM users WHERE username = $1`, + username, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *PgUserRepository) FindByID(ctx context.Context, id string) (*User, error) { + var u User + err := r.pool.QueryRow(ctx, + `SELECT id, username, password_hash, created_at, updated_at FROM users WHERE id = $1`, + id, + ).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, err + } + return &u, nil +} + +func (r *PgUserRepository) SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, + userID, tokenHash, expiresAt, + ) + return err +} + +func (r *PgUserRepository) FindRefreshToken(ctx context.Context, tokenHash string) (string, error) { + var userID string + err := r.pool.QueryRow(ctx, + `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, + tokenHash, + ).Scan(&userID) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrRefreshTokenNotFound + } + if err != nil { + return "", err + } + return userID, nil +} + +func (r *PgUserRepository) DeleteRefreshToken(ctx context.Context, tokenHash string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM refresh_tokens WHERE token_hash = $1`, + tokenHash, + ) + return err +} + +func (r *PgUserRepository) DeleteUserRefreshTokens(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, + `DELETE FROM refresh_tokens WHERE user_id = $1`, + userID, + ) + return err +} -- 2.49.1 From ce13e5a0481cee5486bb57ed0283bbabb2b8d6b2 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:55:44 +0800 Subject: [PATCH 14/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=86=85?= =?UTF-8?q?=E5=AD=98=20UserRepository=EF=BC=88=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_mem.go | 120 +++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 backend/internal/store/user_mem.go diff --git a/backend/internal/store/user_mem.go b/backend/internal/store/user_mem.go new file mode 100644 index 0000000..73a1be2 --- /dev/null +++ b/backend/internal/store/user_mem.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" +) + +// MemUserRepository 基于内存的 UserRepository 实现(测试用)。 +type MemUserRepository struct { + mu sync.RWMutex + users map[string]*User // id -> user + byUsername map[string]string // username -> id + refreshTokens map[string]string // tokenHash -> userID + tokenExpiry map[string]time.Time // tokenHash -> expiresAt +} + +// NewMemUserRepository 创建 MemUserRepository。 +func NewMemUserRepository() *MemUserRepository { + return &MemUserRepository{ + users: make(map[string]*User), + byUsername: make(map[string]string), + refreshTokens: make(map[string]string), + tokenExpiry: make(map[string]time.Time), + } +} + +func (r *MemUserRepository) Create(_ context.Context, username, passwordHash string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.byUsername[username]; exists { + return "", ErrUsernameTaken + } + + id := uuid.New().String() + now := time.Now() + user := &User{ + ID: id, + Username: username, + PasswordHash: passwordHash, + CreatedAt: now, + UpdatedAt: now, + } + r.users[id] = user + r.byUsername[username] = id + return id, nil +} + +func (r *MemUserRepository) FindByUsername(_ context.Context, username string) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + id, ok := r.byUsername[username] + if !ok { + return nil, ErrUserNotFound + } + u := r.users[id] + copy := *u + return ©, nil +} + +func (r *MemUserRepository) FindByID(_ context.Context, id string) (*User, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + u, ok := r.users[id] + if !ok { + return nil, ErrUserNotFound + } + copy := *u + return ©, nil +} + +func (r *MemUserRepository) SaveRefreshToken(_ context.Context, userID, tokenHash string, expiresAt time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + + r.refreshTokens[tokenHash] = userID + r.tokenExpiry[tokenHash] = expiresAt + return nil +} + +func (r *MemUserRepository) FindRefreshToken(_ context.Context, tokenHash string) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + userID, ok := r.refreshTokens[tokenHash] + if !ok { + return "", ErrRefreshTokenNotFound + } + if time.Now().After(r.tokenExpiry[tokenHash]) { + return "", ErrRefreshTokenNotFound + } + return userID, nil +} + +func (r *MemUserRepository) DeleteRefreshToken(_ context.Context, tokenHash string) error { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.refreshTokens, tokenHash) + delete(r.tokenExpiry, tokenHash) + return nil +} + +func (r *MemUserRepository) DeleteUserRefreshTokens(_ context.Context, userID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + for hash, uid := range r.refreshTokens { + if uid == userID { + delete(r.refreshTokens, hash) + delete(r.tokenExpiry, hash) + } + } + return nil +} -- 2.49.1 From 9842b4a45723872c45c231c859eada00588139f5 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 16:56:29 +0800 Subject: [PATCH 15/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20UserReposito?= =?UTF-8?q?ry=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/user_test.go | 185 ++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 backend/internal/store/user_test.go diff --git a/backend/internal/store/user_test.go b/backend/internal/store/user_test.go new file mode 100644 index 0000000..d335933 --- /dev/null +++ b/backend/internal/store/user_test.go @@ -0,0 +1,185 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +// newUserRepo 返回一个可测试的 UserRepository 实现。 +// 如需测试 Pg 实现,可在此替换为连接真实 DB 的版本。 +func newUserRepo() UserRepository { + return NewMemUserRepository() +} + +func TestUserRepository_Create(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + id, err := repo.Create(ctx, "alice", "hash123") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + if id == "" { + t.Fatal("expected non-empty ID") + } + + // 重复用户名应返回 ErrUsernameTaken + _, err = repo.Create(ctx, "alice", "hash456") + if !errors.Is(err, ErrUsernameTaken) { + t.Fatalf("expected ErrUsernameTaken, got %v", err) + } +} + +func TestUserRepository_FindByUsername(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + _, err := repo.Create(ctx, "bob", "hash_bob") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + user, err := repo.FindByUsername(ctx, "bob") + if err != nil { + t.Fatalf("FindByUsername failed: %v", err) + } + if user.Username != "bob" { + t.Fatalf("expected username bob, got %s", user.Username) + } + if user.PasswordHash != "hash_bob" { + t.Fatalf("expected password hash hash_bob, got %s", user.PasswordHash) + } + + // 不存在的用户 + _, err = repo.FindByUsername(ctx, "nobody") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound, got %v", err) + } +} + +func TestUserRepository_FindByID(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + id, err := repo.Create(ctx, "charlie", "hash_charlie") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + user, err := repo.FindByID(ctx, id) + if err != nil { + t.Fatalf("FindByID failed: %v", err) + } + if user.ID != id { + t.Fatalf("expected ID %s, got %s", id, user.ID) + } + if user.Username != "charlie" { + t.Fatalf("expected username charlie, got %s", user.Username) + } + + // 不存在的 ID + _, err = repo.FindByID(ctx, "nonexistent-uuid") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound, got %v", err) + } +} + +func TestUserRepository_RefreshToken(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "dave", "hash_dave") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + tokenHash := "abc123hash" + expiresAt := time.Now().Add(7 * 24 * time.Hour) + + // 保存 token + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + + // 查找 token + foundUserID, err := repo.FindRefreshToken(ctx, tokenHash) + if err != nil { + t.Fatalf("FindRefreshToken failed: %v", err) + } + if foundUserID != userID { + t.Fatalf("expected userID %s, got %s", userID, foundUserID) + } + + // 不存在的 token + _, err = repo.FindRefreshToken(ctx, "nonexistent") + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound, got %v", err) + } + + // 删除 token + if err := repo.DeleteRefreshToken(ctx, tokenHash); err != nil { + t.Fatalf("DeleteRefreshToken failed: %v", err) + } + _, err = repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound after delete, got %v", err) + } +} + +func TestUserRepository_DeleteUserRefreshTokens(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "eve", "hash_eve") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + // 保存多个 token + for i := 0; i < 3; i++ { + tokenHash := "token_" + string(rune('a'+i)) + expiresAt := time.Now().Add(7 * 24 * time.Hour) + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + } + + // 删除用户所有 token + if err := repo.DeleteUserRefreshTokens(ctx, userID); err != nil { + t.Fatalf("DeleteUserRefreshTokens failed: %v", err) + } + + // 验证全部删除 + for i := 0; i < 3; i++ { + tokenHash := "token_" + string(rune('a'+i)) + _, err := repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound for token_%c, got %v", 'a'+i, err) + } + } +} + +func TestUserRepository_ExpiredRefreshToken(t *testing.T) { + repo := newUserRepo() + ctx := context.Background() + + userID, err := repo.Create(ctx, "frank", "hash_frank") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + tokenHash := "expired_token" + expiresAt := time.Now().Add(-1 * time.Hour) // 已过期 + + if err := repo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt); err != nil { + t.Fatalf("SaveRefreshToken failed: %v", err) + } + + // 过期 token 应返回 ErrRefreshTokenNotFound + _, err = repo.FindRefreshToken(ctx, tokenHash) + if !errors.Is(err, ErrRefreshTokenNotFound) { + t.Fatalf("expected ErrRefreshTokenNotFound for expired token, got %v", err) + } +} -- 2.49.1 From 20597101f50f6779b499f3312f664672cc99bde4 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:07:12 +0800 Subject: [PATCH 16/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20TokenManager?= =?UTF-8?q?=EF=BC=88JWT=20=E7=AD=BE=E5=8F=91/=E6=A0=A1=E9=AA=8C/=E5=93=88?= =?UTF-8?q?=E5=B8=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/auth/jwt.go | 111 +++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 backend/internal/auth/jwt.go diff --git a/backend/go.mod b/backend/go.mod index 069496b..d14bd93 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -28,6 +28,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index d4c2b90..38b2a4b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -37,6 +37,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..1da1ac0 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,111 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// 自定义错误。 +var ( + ErrInvalidToken = errors.New("invalid or expired token") +) + +// Claims JWT 声明。 +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + jwt.RegisteredClaims +} + +// TokenManager JWT 令牌管理器。 +type TokenManager struct { + secret []byte + accessTTL time.Duration + refreshTTL time.Duration +} + +// NewTokenManager 创建 TokenManager。 +// secret: JWT 签名密钥;accessTTL/refreshTTL: 令牌有效期。 +func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager { + return &TokenManager{ + secret: []byte(secret), + accessTTL: accessTTL, + refreshTTL: refreshTTL, + } +} + +// GeneratePair 生成 access + refresh 令牌对。 +func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error) { + now := time.Now() + + // access token + accessClaims := &Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(tm.accessTTL)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "camtalk", + }, + } + accessTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + access, err = accessTkn.SignedString(tm.secret) + if err != nil { + return "", "", err + } + + // refresh token(含唯一 token_id 用于 DB 关联) + tokenID := uuid.New().String() + refreshClaims := &Claims{ + UserID: userID, + Username: username, + RegisteredClaims: jwt.RegisteredClaims{ + ID: tokenID, + ExpiresAt: jwt.NewNumericDate(now.Add(tm.refreshTTL)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: "camtalk", + }, + } + refreshTkn := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims) + refresh, err = refreshTkn.SignedString(tm.secret) + return +} + +// ValidateAccess 校验 access token 并返回 Claims。 +func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error) { + return tm.validate(tokenStr) +} + +// ValidateRefresh 校验 refresh token 并返回 Claims。 +func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error) { + return tm.validate(tokenStr) +} + +// validate 解析并校验 JWT。 +func (tm *TokenManager) validate(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return tm.secret, nil + }) + if err != nil { + return nil, ErrInvalidToken + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} + +// HashToken 对 token 做 SHA256 哈希,用于 DB 存储。 +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} -- 2.49.1 From fca9108882ba22ff2f431daa6eb22c91021d4060 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:07:51 +0800 Subject: [PATCH 17/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E5=93=88=E5=B8=8C=E4=B8=8E=E6=A0=A1=E9=AA=8C=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=EF=BC=88bcrypt=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/auth/password.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/internal/auth/password.go diff --git a/backend/internal/auth/password.go b/backend/internal/auth/password.go new file mode 100644 index 0000000..0664268 --- /dev/null +++ b/backend/internal/auth/password.go @@ -0,0 +1,19 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 10 + +// HashPassword 使用 bcrypt 对密码进行哈希。 +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword 校验密码与哈希是否匹配。 +func CheckPassword(hashedPassword, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) +} -- 2.49.1 From 5b8cbb025f178d99018fb22e614483153073ccf5 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:13:54 +0800 Subject: [PATCH 18/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20JWT=20?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E4=B8=AD=E9=97=B4=E4=BB=B6=EF=BC=88AuthMiddl?= =?UTF-8?q?eware=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/auth/middleware.go | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 backend/internal/auth/middleware.go diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..a932240 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,54 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// contextKey 用于在 Gin context 中存储 Claims 的 key。 +const ( + ContextKeyUserID = "user_id" + ContextKeyUsername = "username" +) + +// AuthMiddleware 返回 Gin 中间件,从 Authorization: Bearer 提取并校验 JWT。 +// 校验成功后将 user_id 和 username 写入 Gin Context。 +func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "code": "INVALID_TOKEN", + "message": "missing authorization header", + }) + return + } + + // 提取 Bearer token + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "code": "INVALID_TOKEN", + "message": "invalid authorization format", + }) + return + } + + claims, err := tokenMgr.ValidateAccess(parts[1]) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "code": "INVALID_TOKEN", + "message": "invalid or expired token", + }) + return + } + + // 将用户信息写入 context + c.Set(ContextKeyUserID, claims.UserID) + c.Set(ContextKeyUsername, claims.Username) + + c.Next() + } +} -- 2.49.1 From 5211d14d50eb91416df6653003210f746ad81f48 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:14:52 +0800 Subject: [PATCH 19/46] =?UTF-8?q?feat:=20=E5=AE=9A=E4=B9=89=E5=B9=B6?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20AuthService=EF=BC=88=E6=B3=A8=E5=86=8C/?= =?UTF-8?q?=E7=99=BB=E5=BD=95/=E5=88=B7=E6=96=B0/=E7=99=BB=E5=87=BA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/auth/service.go | 221 +++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 backend/internal/auth/service.go diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..4f18447 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,221 @@ +package auth + +import ( + "context" + "errors" + "time" + + "github.com/hhs/camtalk/internal/store" +) + +// 自定义业务错误。 +var ( + ErrUsernameTaken = errors.New("username already taken") + ErrInvalidCredentials = errors.New("invalid username or password") + ErrRefreshTokenUsed = errors.New("refresh token has been used or expired") +) + +// RegisterRequest 注册请求。 +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// LoginRequest 登录请求。 +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// RefreshRequest 刷新令牌请求。 +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +// AuthResponse 认证响应。 +type AuthResponse struct { + User UserResponse `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// UserResponse 用户信息响应。 +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +// Service 认证业务接口。 +type Service interface { + Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) + Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) + Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) + Logout(ctx context.Context, userID, refreshToken string) error +} + +// authService 认证服务实现。 +type authService struct { + tokenMgr *TokenManager + userRepo store.UserRepository +} + +// NewAuthService 创建认证服务。 +func NewAuthService(tokenMgr *TokenManager, userRepo store.UserRepository) Service { + return &authService{ + tokenMgr: tokenMgr, + userRepo: userRepo, + } +} + +// Register 用户注册。 +func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) { + // 检查用户名是否已存在 + _, err := s.userRepo.FindByUsername(ctx, req.Username) + if err == nil { + return nil, ErrUsernameTaken + } + if !errors.Is(err, store.ErrUserNotFound) { + return nil, err + } + + // 哈希密码 + hash, err := HashPassword(req.Password) + if err != nil { + return nil, err + } + + // 创建用户 + userID, err := s.userRepo.Create(ctx, req.Username, hash) + if err != nil { + if errors.Is(err, store.ErrUsernameTaken) { + return nil, ErrUsernameTaken + } + return nil, err + } + + // 生成令牌对 + access, refresh, err := s.tokenMgr.GeneratePair(userID, req.Username) + if err != nil { + return nil, err + } + + // 保存 refresh token hash 到 DB + if err := s.saveRefreshToken(ctx, userID, refresh); err != nil { + return nil, err + } + + return &AuthResponse{ + User: UserResponse{ + ID: userID, + Username: req.Username, + }, + AccessToken: access, + RefreshToken: refresh, + }, nil +} + +// Login 用户登录。 +func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) { + user, err := s.userRepo.FindByUsername(ctx, req.Username) + if err != nil { + if errors.Is(err, store.ErrUserNotFound) { + return nil, ErrInvalidCredentials + } + return nil, err + } + + // 校验密码 + if err := CheckPassword(user.PasswordHash, req.Password); err != nil { + return nil, ErrInvalidCredentials + } + + // 生成令牌对 + access, refresh, err := s.tokenMgr.GeneratePair(user.ID, user.Username) + if err != nil { + return nil, err + } + + // 保存 refresh token hash + if err := s.saveRefreshToken(ctx, user.ID, refresh); err != nil { + return nil, err + } + + return &AuthResponse{ + User: UserResponse{ + ID: user.ID, + Username: user.Username, + CreatedAt: user.CreatedAt, + }, + AccessToken: access, + RefreshToken: refresh, + }, nil +} + +// Refresh 刷新令牌(Refresh Token Rotation)。 +func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error) { + // 校验 refresh token + claims, err := s.tokenMgr.ValidateRefresh(req.RefreshToken) + if err != nil { + return nil, ErrRefreshTokenUsed + } + + tokenHash := HashToken(req.RefreshToken) + + // 查找 DB 中的 token hash,确认未被使用 + userID, err := s.userRepo.FindRefreshToken(ctx, tokenHash) + if err != nil { + if errors.Is(err, store.ErrRefreshTokenNotFound) { + return nil, ErrRefreshTokenUsed + } + return nil, err + } + + // 确认 token 归属的用户与 claims 一致 + if userID != claims.UserID { + return nil, ErrRefreshTokenUsed + } + + // 删除旧 refresh token(rotation) + _ = s.userRepo.DeleteRefreshToken(ctx, tokenHash) + + // 生成新的令牌对 + access, refresh, err := s.tokenMgr.GeneratePair(claims.UserID, claims.Username) + if err != nil { + return nil, err + } + + // 保存新 refresh token + if err := s.saveRefreshToken(ctx, claims.UserID, refresh); err != nil { + return nil, err + } + + // 查用户信息 + user, err := s.userRepo.FindByID(ctx, claims.UserID) + if err != nil { + return nil, err + } + + return &AuthResponse{ + User: UserResponse{ + ID: user.ID, + Username: user.Username, + CreatedAt: user.CreatedAt, + }, + AccessToken: access, + RefreshToken: refresh, + }, nil +} + +// Logout 登出,删除 refresh token。 +func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error { + tokenHash := HashToken(refreshToken) + return s.userRepo.DeleteRefreshToken(ctx, tokenHash) +} + +// saveRefreshToken 将 refresh token 的 hash 保存到 DB。 +func (s *authService) saveRefreshToken(ctx context.Context, userID, refreshToken string) error { + tokenHash := HashToken(refreshToken) + expiresAt := time.Now().Add(s.tokenMgr.refreshTTL) + return s.userRepo.SaveRefreshToken(ctx, userID, tokenHash, expiresAt) +} -- 2.49.1 From e455af6b2d79da6eefbe844247ad4df78a5a6595 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:17:28 +0800 Subject: [PATCH 20/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20TokenManager?= =?UTF-8?q?=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/auth/jwt_test.go | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 backend/internal/auth/jwt_test.go diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go new file mode 100644 index 0000000..35b12fa --- /dev/null +++ b/backend/internal/auth/jwt_test.go @@ -0,0 +1,127 @@ +package auth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeneratePair_ReturnsNonEmptyTokens(t *testing.T) { + tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + access, refresh, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + assert.NotEmpty(t, access) + assert.NotEmpty(t, refresh) + assert.NotEqual(t, access, refresh) +} + +func TestValidateAccess_ValidToken(t *testing.T) { + tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + claims, err := tm.ValidateAccess(access) + require.NoError(t, err) + assert.Equal(t, "user-123", claims.UserID) + assert.Equal(t, "alice", claims.Username) + assert.Equal(t, "camtalk", claims.Issuer) +} + +func TestValidateRefresh_ValidToken(t *testing.T) { + tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + _, refresh, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + claims, err := tm.ValidateRefresh(refresh) + require.NoError(t, err) + assert.Equal(t, "user-123", claims.UserID) + assert.Equal(t, "alice", claims.Username) + assert.NotEmpty(t, claims.ID) // refresh token 应含唯一 ID +} + +func TestValidateAccess_ExpiredToken(t *testing.T) { + // 使用极短的 TTL + tm := NewTokenManager("test-secret-key", -1*time.Second, -1*time.Second) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + _, err = tm.ValidateAccess(access) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestValidateAccess_WrongSecret(t *testing.T) { + tm1 := NewTokenManager("secret-1", 15*time.Minute, 7*24*time.Hour) + tm2 := NewTokenManager("secret-2", 15*time.Minute, 7*24*time.Hour) + + access, _, err := tm1.GeneratePair("user-123", "alice") + require.NoError(t, err) + + _, err = tm2.ValidateAccess(access) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestValidateAccess_InvalidFormat(t *testing.T) { + tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + _, err := tm.ValidateAccess("not-a-valid-token") + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestValidateAccess_EmptyString(t *testing.T) { + tm := NewTokenManager("test-secret-key", 15*time.Minute, 7*24*time.Hour) + + _, err := tm.ValidateAccess("") + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestHashToken_Deterministic(t *testing.T) { + hash1 := HashToken("some-token-value") + hash2 := HashToken("some-token-value") + assert.Equal(t, hash1, hash2) + assert.Len(t, hash1, 64) // SHA256 hex = 64 chars +} + +func TestHashToken_DifferentInputsDifferentHashes(t *testing.T) { + hash1 := HashToken("token-a") + hash2 := HashToken("token-b") + assert.NotEqual(t, hash1, hash2) +} + +func TestValidateRefresh_ExpiredToken(t *testing.T) { + tm := NewTokenManager("test-secret-key", -1*time.Minute, -1*time.Minute) + + _, refresh, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + _, err = tm.ValidateRefresh(refresh) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestGeneratePair_TokenClaimsContainCorrectExpiry(t *testing.T) { + accessTTL := 15 * time.Minute + refreshTTL := 7 * 24 * time.Hour + tm := NewTokenManager("test-secret-key", accessTTL, refreshTTL) + + before := time.Now() + access, refresh, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + after := time.Now() + + // 校验 access token 有效期范围 + accessClaims, err := tm.ValidateAccess(access) + require.NoError(t, err) + assert.True(t, accessClaims.ExpiresAt.Time.After(before.Add(accessTTL).Add(-1*time.Second))) + assert.True(t, accessClaims.ExpiresAt.Time.Before(after.Add(accessTTL).Add(1*time.Second))) + + // 校验 refresh token 有效期范围 + refreshClaims, err := tm.ValidateRefresh(refresh) + require.NoError(t, err) + assert.True(t, refreshClaims.ExpiresAt.Time.After(before.Add(refreshTTL).Add(-1*time.Second))) + assert.True(t, refreshClaims.ExpiresAt.Time.Before(after.Add(refreshTTL).Add(1*time.Second))) +} -- 2.49.1 From f87898804813378f419a1efa3c18021a623d31ab Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:18:22 +0800 Subject: [PATCH 21/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20AuthService?= =?UTF-8?q?=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95=EF=BC=88mock=20UserRepo?= =?UTF-8?q?sitory=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/auth/service_test.go | 190 ++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 backend/internal/auth/service_test.go diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..9fc91a5 --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,190 @@ +package auth_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hhs/camtalk/internal/auth" + "github.com/hhs/camtalk/internal/store" +) + +// newTestService 创建测试用的 AuthService + MemUserRepository。 +func newTestService(t *testing.T) (auth.Service, *store.MemUserRepository) { + t.Helper() + tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + repo := store.NewMemUserRepository() + svc := auth.NewAuthService(tm, repo) + return svc, repo +} + +// --- Register --- + +func TestRegister_Success(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + resp, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "alice", + Password: "password123", + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.User.ID) + assert.Equal(t, "alice", resp.User.Username) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) +} + +func TestRegister_DuplicateUsername(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "alice", + Password: "password123", + }) + require.NoError(t, err) + + // 同名再次注册 + _, err = svc.Register(ctx, auth.RegisterRequest{ + Username: "alice", + Password: "another-password", + }) + assert.ErrorIs(t, err, auth.ErrUsernameTaken) +} + +// --- Login --- + +func TestLogin_Success(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + // 先注册 + _, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "bob", + Password: "password123", + }) + require.NoError(t, err) + + // 登录 + resp, err := svc.Login(ctx, auth.LoginRequest{ + Username: "bob", + Password: "password123", + }) + require.NoError(t, err) + assert.Equal(t, "bob", resp.User.Username) + assert.NotEmpty(t, resp.AccessToken) + assert.NotEmpty(t, resp.RefreshToken) +} + +func TestLogin_WrongPassword(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "bob", + Password: "password123", + }) + require.NoError(t, err) + + _, err = svc.Login(ctx, auth.LoginRequest{ + Username: "bob", + Password: "wrong-password", + }) + assert.ErrorIs(t, err, auth.ErrInvalidCredentials) +} + +func TestLogin_UserNotFound(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.Login(ctx, auth.LoginRequest{ + Username: "nonexistent", + Password: "password123", + }) + assert.ErrorIs(t, err, auth.ErrInvalidCredentials) +} + +// --- Refresh --- + +func TestRefresh_Success(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + // 注册 + regResp, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "charlie", + Password: "password123", + }) + require.NoError(t, err) + + // 刷新 + refreshResp, err := svc.Refresh(ctx, auth.RefreshRequest{ + RefreshToken: regResp.RefreshToken, + }) + require.NoError(t, err) + assert.Equal(t, "charlie", refreshResp.User.Username) + assert.NotEmpty(t, refreshResp.AccessToken) + assert.NotEmpty(t, refreshResp.RefreshToken) + // 新旧 refresh token 应不同(rotation) + assert.NotEqual(t, regResp.RefreshToken, refreshResp.RefreshToken) +} + +func TestRefresh_UsedTokenFails(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + regResp, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "charlie", + Password: "password123", + }) + require.NoError(t, err) + + // 第一次刷新 + _, err = svc.Refresh(ctx, auth.RefreshRequest{ + RefreshToken: regResp.RefreshToken, + }) + require.NoError(t, err) + + // 用旧 token 再次刷新 → 应失败 + _, err = svc.Refresh(ctx, auth.RefreshRequest{ + RefreshToken: regResp.RefreshToken, + }) + assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed) +} + +func TestRefresh_InvalidToken(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + _, err := svc.Refresh(ctx, auth.RefreshRequest{ + RefreshToken: "completely-invalid-token", + }) + assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed) +} + +// --- Logout --- + +func TestLogout_Success(t *testing.T) { + svc, _ := newTestService(t) + ctx := context.Background() + + regResp, err := svc.Register(ctx, auth.RegisterRequest{ + Username: "dave", + Password: "password123", + }) + require.NoError(t, err) + + // 登出 + err = svc.Logout(ctx, regResp.User.ID, regResp.RefreshToken) + require.NoError(t, err) + + // 登出后 refresh token 应失效 + _, err = svc.Refresh(ctx, auth.RefreshRequest{ + RefreshToken: regResp.RefreshToken, + }) + assert.ErrorIs(t, err, auth.ErrRefreshTokenUsed) +} -- 2.49.1 From fdea3aa9d1f124dd3e18528c98ce9d8a7ebb9b6b Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:24:57 +0800 Subject: [PATCH 22/46] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E7=9B=B8=E5=85=B3=E9=94=99=E8=AF=AF=E7=A0=81=EF=BC=88?= =?UTF-8?q?USERNAME=5FTAKEN,=20INVALID=5FCREDENTIALS,=20INVALID=5FTOKEN,?= =?UTF-8?q?=20INVALID=5FINPUT=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/errors/codes.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/errors/codes.go b/backend/internal/errors/codes.go index 739cfdb..9417209 100644 --- a/backend/internal/errors/codes.go +++ b/backend/internal/errors/codes.go @@ -14,6 +14,12 @@ const ( CodeSTTError = "STT_ERROR" CodeTTSError = "TTS_ERROR" CodeInternalError = "INTERNAL_ERROR" + + // 认证相关错误码 + CodeUsernameTaken = "USERNAME_TAKEN" + CodeInvalidCredentials = "INVALID_CREDENTIALS" + CodeInvalidToken = "INVALID_TOKEN" + CodeInvalidInput = "INVALID_INPUT" ) // Sender 定义发送 WS 错误消息的接口,便于测试 mock。 -- 2.49.1 From 15b437043ff0fb57838474e2498580ece25a3c10 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:25:50 +0800 Subject: [PATCH 23/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20AuthHandler?= =?UTF-8?q?=EF=BC=88=E6=B3=A8=E5=86=8C/=E7=99=BB=E5=BD=95/=E5=88=B7?= =?UTF-8?q?=E6=96=B0/=E7=99=BB=E5=87=BA=E7=AB=AF=E7=82=B9=20+=20=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A0=A1=E9=AA=8C=20+=20=E8=B7=AF=E7=94=B1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/auth.go | 194 +++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 backend/internal/api/auth.go diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..3b886c3 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,194 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/hhs/camtalk/internal/auth" + apperr "github.com/hhs/camtalk/internal/errors" +) + +// AuthHandler 提供认证相关的 REST 端点。 +type AuthHandler struct { + authService auth.Service + tokenMgr *auth.TokenManager +} + +// NewAuthHandler 创建 AuthHandler。 +func NewAuthHandler(authService auth.Service, tokenMgr *auth.TokenManager) *AuthHandler { + return &AuthHandler{ + authService: authService, + tokenMgr: tokenMgr, + } +} + +// RegisterRoutes 注册认证相关路由到给定的路由组。 +func (h *AuthHandler) RegisterRoutes(rg *gin.RouterGroup) { + authGroup := rg.Group("/auth") + { + authGroup.POST("/register", h.Register) + authGroup.POST("/login", h.Login) + authGroup.POST("/refresh", h.Refresh) + authGroup.POST("/logout", auth.AuthMiddleware(h.tokenMgr), h.Logout) + } +} + +// Register POST /api/auth/register — 用户注册。 +func (h *AuthHandler) Register(c *gin.Context) { + var req auth.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "invalid request body", + }) + return + } + + if msg := validateCredentials(req.Username, req.Password); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": msg, + }) + return + } + + resp, err := h.authService.Register(c.Request.Context(), req) + if err != nil { + handleAuthError(c, err) + return + } + + c.JSON(http.StatusCreated, resp) +} + +// Login POST /api/auth/login — 用户登录。 +func (h *AuthHandler) Login(c *gin.Context) { + var req auth.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "invalid request body", + }) + return + } + + if msg := validateCredentials(req.Username, req.Password); msg != "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": msg, + }) + return + } + + resp, err := h.authService.Login(c.Request.Context(), req) + if err != nil { + handleAuthError(c, err) + return + } + + c.JSON(http.StatusOK, resp) +} + +// Refresh POST /api/auth/refresh — 刷新令牌。 +func (h *AuthHandler) Refresh(c *gin.Context) { + var req auth.RefreshRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "invalid request body", + }) + return + } + + if req.RefreshToken == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "refresh_token is required", + }) + return + } + + resp, err := h.authService.Refresh(c.Request.Context(), req) + if err != nil { + handleAuthError(c, err) + return + } + + c.JSON(http.StatusOK, resp) +} + +// Logout POST /api/auth/logout — 登出(需要认证)。 +func (h *AuthHandler) Logout(c *gin.Context) { + userID := c.GetString(auth.ContextKeyUserID) + + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "invalid request body", + }) + return + } + + if req.RefreshToken == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "refresh_token is required", + }) + return + } + + if err := h.authService.Logout(c.Request.Context(), userID, req.RefreshToken); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to logout", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "logged out successfully", + }) +} + +// validateCredentials 校验用户名和密码格式。 +// 返回空字符串表示校验通过,否则返回错误描述。 +func validateCredentials(username, password string) string { + if len(username) < 3 || len(username) > 64 { + return "username must be 3-64 characters" + } + if len(password) < 8 || len(password) > 72 { + return "password must be 8-72 characters" + } + return "" +} + +// handleAuthError 将 auth 层错误映射为 HTTP 响应。 +func handleAuthError(c *gin.Context, err error) { + switch { + case errors.Is(err, auth.ErrUsernameTaken): + c.JSON(http.StatusConflict, gin.H{ + "code": apperr.CodeUsernameTaken, + "message": "username already taken", + }) + case errors.Is(err, auth.ErrInvalidCredentials): + c.JSON(http.StatusUnauthorized, gin.H{ + "code": apperr.CodeInvalidCredentials, + "message": "invalid username or password", + }) + case errors.Is(err, auth.ErrRefreshTokenUsed): + c.JSON(http.StatusUnauthorized, gin.H{ + "code": apperr.CodeInvalidToken, + "message": "refresh token has been used or expired", + }) + default: + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "internal server error", + }) + } +} -- 2.49.1 From 9aaed88cc10422f439cb624607bd05896c968a42 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:26:50 +0800 Subject: [PATCH 24/46] =?UTF-8?q?feat:=20main.go=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E6=9C=8D=E5=8A=A1=EF=BC=88TokenManager=20+?= =?UTF-8?q?=20AuthService=20+=20AuthHandler=20=E8=B7=AF=E7=94=B1=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8091719..9bc51b0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/hhs/camtalk/internal/api" + "github.com/hhs/camtalk/internal/auth" "github.com/hhs/camtalk/internal/ai/llm" "github.com/hhs/camtalk/internal/ai/stt" "github.com/hhs/camtalk/internal/ai/tts" @@ -60,6 +61,10 @@ func main() { _ = pool } + // 初始化 UserRepository(内存模式用于无 DB 场景) + var userRepo store.UserRepository + userRepo = store.NewMemUserRepository() + // 初始化 Session Manager(MVP 默认内存实现) var sessionMgr session.Manager // TODO: 当 Redis 配置非空时切换为 RedisManager @@ -105,6 +110,14 @@ func main() { // 初始化 Orchestrator orch := orchestrator.New(sttService, llmService, ttsService, sessionMgr, cfg) + // 初始化认证服务 + tokenMgr := auth.NewTokenManager( + cfg.Auth.JWTSecret, + time.Duration(cfg.Auth.AccessTTL)*time.Minute, + time.Duration(cfg.Auth.RefreshTTL)*time.Minute, + ) + authService := auth.NewAuthService(tokenMgr, userRepo) + // Gin 模式 if cfg.App.Env == "prod" { gin.SetMode(gin.ReleaseMode) @@ -123,6 +136,10 @@ func main() { sessionHandler := api.NewSessionHandler(sessionMgr) sessionHandler.RegisterRoutes(apiGroup) + // Auth REST 端点 + authHandler := api.NewAuthHandler(authService, tokenMgr) + authHandler.RegisterRoutes(apiGroup) + // WebSocket r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg)) -- 2.49.1 From c01ee1d14c5fe1e0d8f65e631990ac78bfed0053 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:28:13 +0800 Subject: [PATCH 25/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20AuthHandler?= =?UTF-8?q?=20API=20=E6=B5=8B=E8=AF=95=EF=BC=88httptest=20+=20mock=20AuthS?= =?UTF-8?q?ervice=EF=BC=8C=E8=A6=86=E7=9B=96=E5=85=A8=E9=83=A8=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/api/auth_test.go | 324 ++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 backend/internal/api/auth_test.go diff --git a/backend/internal/api/auth_test.go b/backend/internal/api/auth_test.go new file mode 100644 index 0000000..f49b75a --- /dev/null +++ b/backend/internal/api/auth_test.go @@ -0,0 +1,324 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hhs/camtalk/internal/api" + "github.com/hhs/camtalk/internal/auth" +) + +// mockAuthService 实现 auth.Service 接口,用于 API 测试。 +type mockAuthService struct { + RegisterFunc func(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) + LoginFunc func(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) + RefreshFunc func(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) + LogoutFunc func(ctx context.Context, userID, refreshToken string) error +} + +func (m *mockAuthService) Register(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) { + return m.RegisterFunc(ctx, req) +} + +func (m *mockAuthService) Login(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) { + return m.LoginFunc(ctx, req) +} + +func (m *mockAuthService) Refresh(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) { + return m.RefreshFunc(ctx, req) +} + +func (m *mockAuthService) Logout(ctx context.Context, userID, refreshToken string) error { + return m.LogoutFunc(ctx, userID, refreshToken) +} + +// newTestRouter 创建带 AuthHandler 路由的测试 Gin 引擎。 +func newTestRouter(svc auth.Service) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + h := api.NewAuthHandler(svc, tm) + h.RegisterRoutes(r.Group("/api")) + return r +} + +// newTestRouterWithToken 创建带 AuthHandler 路由的测试引擎,同时返回 TokenManager 以便生成测试 token。 +func newTestRouterWithToken(svc auth.Service) (*gin.Engine, *auth.TokenManager) { + gin.SetMode(gin.TestMode) + r := gin.New() + tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + h := api.NewAuthHandler(svc, tm) + h.RegisterRoutes(r.Group("/api")) + return r, tm +} + +func sampleAuthResponse() *auth.AuthResponse { + return &auth.AuthResponse{ + User: auth.UserResponse{ + ID: "user-123", + Username: "alice", + }, + AccessToken: "access-token", + RefreshToken: "refresh-token", + } +} + +// --- Register --- + +func TestRegister_Success(t *testing.T) { + svc := &mockAuthService{ + RegisterFunc: func(_ context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) { + assert.Equal(t, "alice", req.Username) + assert.Equal(t, "password123", req.Password) + return sampleAuthResponse(), nil + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + var resp auth.AuthResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "alice", resp.User.Username) + assert.NotEmpty(t, resp.AccessToken) +} + +func TestRegister_InvalidInput_EmptyBody(t *testing.T) { + svc := &mockAuthService{} + r := newTestRouter(svc) + + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "INVALID_INPUT") +} + +func TestRegister_InvalidInput_UsernameTooShort(t *testing.T) { + svc := &mockAuthService{} + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RegisterRequest{Username: "ab", Password: "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "username must be 3-64 characters") +} + +func TestRegister_InvalidInput_PasswordTooShort(t *testing.T) { + svc := &mockAuthService{} + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "short"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "password must be 8-72 characters") +} + +func TestRegister_UsernameTaken(t *testing.T) { + svc := &mockAuthService{ + RegisterFunc: func(_ context.Context, _ auth.RegisterRequest) (*auth.AuthResponse, error) { + return nil, auth.ErrUsernameTaken + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + assert.Contains(t, w.Body.String(), "USERNAME_TAKEN") +} + +// --- Login --- + +func TestLogin_Success(t *testing.T) { + svc := &mockAuthService{ + LoginFunc: func(_ context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) { + assert.Equal(t, "alice", req.Username) + assert.Equal(t, "password123", req.Password) + return sampleAuthResponse(), nil + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "password123"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp auth.AuthResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "alice", resp.User.Username) +} + +func TestLogin_InvalidCredentials(t *testing.T) { + svc := &mockAuthService{ + LoginFunc: func(_ context.Context, _ auth.LoginRequest) (*auth.AuthResponse, error) { + return nil, auth.ErrInvalidCredentials + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "wrong-password"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "INVALID_CREDENTIALS") +} + +// --- Refresh --- + +func TestRefresh_Success(t *testing.T) { + svc := &mockAuthService{ + RefreshFunc: func(_ context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) { + assert.Equal(t, "some-refresh-token", req.RefreshToken) + return sampleAuthResponse(), nil + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "some-refresh-token"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestRefresh_MissingToken(t *testing.T) { + svc := &mockAuthService{} + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: ""}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "refresh_token is required") +} + +func TestRefresh_UsedToken(t *testing.T) { + svc := &mockAuthService{ + RefreshFunc: func(_ context.Context, _ auth.RefreshRequest) (*auth.AuthResponse, error) { + return nil, auth.ErrRefreshTokenUsed + }, + } + r := newTestRouter(svc) + + body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "used-token"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "INVALID_TOKEN") +} + +// --- Logout --- + +func TestLogout_Success(t *testing.T) { + logoutCalled := false + svc := &mockAuthService{ + LogoutFunc: func(_ context.Context, userID, refreshToken string) error { + assert.Equal(t, "user-123", userID) + assert.Equal(t, "refresh-token-to-revoke", refreshToken) + logoutCalled = true + return nil + }, + } + r, tm := newTestRouterWithToken(svc) + + // 生成有效 token + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + body, _ := json.Marshal(map[string]string{"refresh_token": "refresh-token-to-revoke"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.True(t, logoutCalled) + assert.Contains(t, w.Body.String(), "logged out successfully") +} + +func TestLogout_MissingAuth(t *testing.T) { + svc := &mockAuthService{} + r := newTestRouter(svc) + + body, _ := json.Marshal(map[string]string{"refresh_token": "some-token"}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestLogout_MissingRefreshToken(t *testing.T) { + svc := &mockAuthService{} + r, tm := newTestRouterWithToken(svc) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + body, _ := json.Marshal(map[string]string{"refresh_token": ""}) + req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "refresh_token is required") +} -- 2.49.1 From 9ff971fd898cb18182b8f837cf908c9f1f28d960 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:30:59 +0800 Subject: [PATCH 26/46] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Session=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=EF=BC=8C=E6=96=B0=E5=A2=9E=20UserID=E3=80=81?= =?UTF-8?q?Title=E3=80=81UpdatedAt=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/models/models.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index e83680b..e6bf9f3 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -5,7 +5,10 @@ import "time" // Session 会话。 type Session struct { ID string `json:"session_id"` + UserID string `json:"user_id,omitempty"` + Title string `json:"title"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Config SessionConfig `json:"config"` } @@ -16,6 +19,9 @@ type SessionConfig struct { Language string `json:"language"` } +// DefaultSessionTitle 默认会话标题。 +const DefaultSessionTitle = "新对话" + // DefaultConfig 默认会话配置。 func DefaultConfig() SessionConfig { return SessionConfig{TTSEnabled: true, DetailLevel: "low", Language: "zh-CN"} -- 2.49.1 From 6487a8ecab5d73658427cb4407314e68a44f1ec1 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:35:20 +0800 Subject: [PATCH 27/46] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=20Session=20Ma?= =?UTF-8?q?nager=20=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=96=B0=E5=A2=9E=20ListByU?= =?UTF-8?q?ser=E3=80=81UpdateTitle=20=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Manager.Create 签名新增 userID 参数 - 新增 ConversationSummary 类型和 ListByUser 分页查询 - 新增 UpdateTitle 方法 - MemoryManager 实现:ListByUser 遍历+过滤+排序,UpdateTitle,自动标题生成 - RedisManager 实现:user:{id}:sessions 索引,ListByUser 通过 SMEMBERS 查询 - AppendMessage 自动更新标题(首条 user 消息时,取前 20 字符) - 更新 ws handler、api/session.go、orchestrator mock 的 Create 调用 --- backend/internal/api/session.go | 2 +- .../internal/orchestrator/pipeline_test.go | 15 +- backend/internal/session/manager.go | 20 +- backend/internal/session/memory.go | 98 +++++++++- backend/internal/session/memory_test.go | 22 +-- backend/internal/session/redis.go | 172 ++++++++++++++++-- backend/internal/ws/handler.go | 2 +- 7 files changed, 293 insertions(+), 38 deletions(-) diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 135758b..78bae2d 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -36,7 +36,7 @@ func (h *SessionHandler) CreateSession(c *gin.Context) { cfg = *req.Config } - sessionID, err := h.sessionMgr.Create(c.Request.Context(), cfg) + sessionID, err := h.sessionMgr.Create(c.Request.Context(), "", cfg) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "code": "INTERNAL_ERROR", diff --git a/backend/internal/orchestrator/pipeline_test.go b/backend/internal/orchestrator/pipeline_test.go index ad9b847..9000e96 100644 --- a/backend/internal/orchestrator/pipeline_test.go +++ b/backend/internal/orchestrator/pipeline_test.go @@ -16,6 +16,7 @@ import ( "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" + "github.com/hhs/camtalk/internal/session" ) func init() { @@ -63,11 +64,21 @@ type MockSessionManager struct { mock.Mock } -func (m *MockSessionManager) Create(ctx context.Context, config models.SessionConfig) (string, error) { - args := m.Called(ctx, config) +func (m *MockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) { + args := m.Called(ctx, userID, config) return args.String(0), args.Error(1) } +func (m *MockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error { + args := m.Called(ctx, sessionID, title) + return args.Error(0) +} + +func (m *MockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) { + args := m.Called(ctx, userID, page, size) + return args.Get(0).([]session.ConversationSummary), args.Int(1), args.Error(2) +} + func (m *MockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) { args := m.Called(ctx, sessionID) if args.Get(0) == nil { diff --git a/backend/internal/session/manager.go b/backend/internal/session/manager.go index 6e7f0eb..6a2527e 100644 --- a/backend/internal/session/manager.go +++ b/backend/internal/session/manager.go @@ -4,6 +4,7 @@ package session import ( "context" "errors" + "time" "github.com/hhs/camtalk/internal/models" ) @@ -11,11 +12,20 @@ import ( // ErrSessionNotFound 会话不存在或已过期。 var ErrSessionNotFound = errors.New("session not found") +// ConversationSummary 对话摘要(列表展示用)。 +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + LastMessage string `json:"last_message"` + MessageCount int `json:"message_count"` + UpdatedAt time.Time `json:"updated_at"` +} + // Manager 会话管理器接口。 // WebSocket Handler 通过此接口操作会话,不直接接触存储层。 type Manager interface { - // Create 创建新会话,返回 session ID。 - Create(ctx context.Context, config models.SessionConfig) (string, error) + // Create 创建新会话,返回 session ID。userID 为空表示匿名会话。 + Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) // Get 获取会话(含 config)。不存在返回 ErrSessionNotFound。 Get(ctx context.Context, sessionID string) (*models.Session, error) @@ -23,6 +33,12 @@ type Manager interface { // UpdateConfig 更新会话配置(config 消息触发)。 UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error + // UpdateTitle 更新会话标题。 + UpdateTitle(ctx context.Context, sessionID string, title string) error + + // ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。 + ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) + // GetHistory 获取最近 N 轮对话历史(供 Orchestrator 构建 LLM 上下文)。 GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index edea8cb..2df0be3 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -2,6 +2,7 @@ package session import ( "context" + "sort" "sync" "time" @@ -95,8 +96,8 @@ func (m *MemoryManager) isExpired(entry *sessionEntry) bool { return time.Since(entry.lastActive) > m.ttl } -// Create 创建新会话。 -func (m *MemoryManager) Create(_ context.Context, config models.SessionConfig) (string, error) { +// Create 创建新会话。userID 为空表示匿名会话。 +func (m *MemoryManager) Create(_ context.Context, userID string, config models.SessionConfig) (string, error) { m.mu.Lock() defer m.mu.Unlock() @@ -105,14 +106,17 @@ func (m *MemoryManager) Create(_ context.Context, config models.SessionConfig) ( m.sessions[id] = &sessionEntry{ session: models.Session{ ID: id, + UserID: userID, + Title: models.DefaultSessionTitle, CreatedAt: now, + UpdatedAt: now, Config: config, }, history: make([]models.Message, 0), lastActive: now, } - logger.Log.Debugw("session created", "session", id) + logger.Log.Debugw("session created", "session", id, "user_id", userID) return id, nil } @@ -147,6 +151,76 @@ func (m *MemoryManager) UpdateConfig(_ context.Context, sessionID string, patch return nil } +// UpdateTitle 更新会话标题。 +func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title string) error { + m.mu.Lock() + defer m.mu.Unlock() + + entry, ok := m.sessions[sessionID] + if !ok || m.isExpired(entry) { + return ErrSessionNotFound + } + + entry.session.Title = title + entry.session.UpdatedAt = time.Now() + entry.lastActive = time.Now() + + logger.Log.Debugw("session title updated", "session", sessionID, "title", title) + return nil +} + +// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。 +func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + // 收集该用户的所有 session + var list []ConversationSummary + for _, entry := range m.sessions { + if entry.session.UserID != userID { + continue + } + if m.isExpired(entry) { + continue + } + summary := ConversationSummary{ + ID: entry.session.ID, + Title: entry.session.Title, + MessageCount: len(entry.history), + UpdatedAt: entry.lastActive, + } + if len(entry.history) > 0 { + summary.LastMessage = entry.history[len(entry.history)-1].Content + } + list = append(list, summary) + } + + // 按 UpdatedAt 降序排序 + sort.Slice(list, func(i, j int) bool { + return list[i].UpdatedAt.After(list[j].UpdatedAt) + }) + + total := len(list) + + // 分页 + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + start := (page - 1) * size + if start >= total { + return []ConversationSummary{}, total, nil + } + end := start + size + if end > total { + end = total + } + + return list[start:end], total, nil +} + // GetHistory 获取最近 N 轮对话历史。 func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit int) ([]models.Message, error) { m.mu.RLock() @@ -179,15 +253,31 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m entry.history = append(entry.history, msg) + // 自动更新标题:首条 user 消息时,如果标题为默认值,自动更新为消息前 20 字符 + if msg.Role == "user" && entry.session.Title == models.DefaultSessionTitle { + entry.session.Title = generateTitle(msg.Content) + } + // 超过上限时裁剪,保留最新的 maxHistory 条 if len(entry.history) > m.maxHistory { entry.history = entry.history[len(entry.history)-m.maxHistory:] } - entry.lastActive = time.Now() + now := time.Now() + entry.lastActive = now + entry.session.UpdatedAt = now return nil } +// generateTitle 从首条消息生成对话标题(取前 20 个字符)。 +func generateTitle(firstMessage string) string { + runes := []rune(firstMessage) + if len(runes) > 20 { + return string(runes[:20]) + "…" + } + return firstMessage +} + // SetActiveRequest 标记当前正在处理的请求 ID。 func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error { m.mu.Lock() diff --git a/backend/internal/session/memory_test.go b/backend/internal/session/memory_test.go index acfa433..eb37b73 100644 --- a/backend/internal/session/memory_test.go +++ b/backend/internal/session/memory_test.go @@ -19,7 +19,7 @@ func TestCreateAndGet(t *testing.T) { ctx := context.Background() config := models.DefaultConfig() - id, err := m.Create(ctx, config) + id, err := m.Create(ctx, "", config) if err != nil { t.Fatalf("Create: %v", err) } @@ -56,7 +56,7 @@ func TestExpire(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) // 未过期时应能获取 _, err := m.Get(ctx, id) @@ -78,7 +78,7 @@ func TestDestroy(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) if err := m.Destroy(ctx, id); err != nil { t.Fatalf("Destroy: %v", err) @@ -106,7 +106,7 @@ func TestAppendMessageAndGetHistory(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) msgs := []models.Message{ {Role: "user", Content: "你好"}, @@ -138,7 +138,7 @@ func TestGetHistoryLimit(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) for i := 0; i < 10; i++ { m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"}) @@ -159,7 +159,7 @@ func TestHistoryLimit(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) // 插入超过上限的消息 for i := 0; i < 10; i++ { @@ -180,7 +180,7 @@ func TestUpdateConfig(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) ttsEnabled := false detailLevel := "high" @@ -211,7 +211,7 @@ func TestActiveRequest(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) // 初始应为空 reqID, err := m.GetActiveRequestID(ctx, id) @@ -246,7 +246,7 @@ func TestTouchRefreshesTTL(t *testing.T) { defer m.Stop() ctx := context.Background() - id, _ := m.Create(ctx, models.DefaultConfig()) + id, _ := m.Create(ctx, "", models.DefaultConfig()) // 50ms 后 Touch,应重置 TTL time.Sleep(50 * time.Millisecond) @@ -278,8 +278,8 @@ func TestActiveCount(t *testing.T) { t.Errorf("initial ActiveCount = %d, want 0", m.ActiveCount()) } - m.Create(ctx, models.DefaultConfig()) - m.Create(ctx, models.DefaultConfig()) + m.Create(ctx, "", models.DefaultConfig()) + m.Create(ctx, "", models.DefaultConfig()) if m.ActiveCount() != 2 { t.Errorf("ActiveCount = %d, want 2", m.ActiveCount()) } diff --git a/backend/internal/session/redis.go b/backend/internal/session/redis.go index ae24f11..ec1dc6b 100644 --- a/backend/internal/session/redis.go +++ b/backend/internal/session/redis.go @@ -18,6 +18,7 @@ import ( // 数据结构: // - session:{id}:meta → Hash(会话元数据) // - session:{id}:history → List(对话历史) +// - user:{id}:sessions → Set(用户会话索引) type RedisManager struct { rdb *redis.Client ttl time.Duration @@ -35,37 +36,48 @@ func NewRedisManager(rdb *redis.Client, ttl time.Duration, maxHistory int) *Redi return &RedisManager{rdb: rdb, ttl: ttl, maxHistory: maxHistory} } -func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) } -func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) } +func metaKey(id string) string { return fmt.Sprintf("session:%s:meta", id) } +func histKey(id string) string { return fmt.Sprintf("session:%s:history", id) } +func userSessKey(id string) string { return fmt.Sprintf("user:%s:sessions", id) } -// Create 创建新会话。 -func (m *RedisManager) Create(ctx context.Context, config models.SessionConfig) (string, error) { +// Create 创建新会话。userID 为空表示匿名会话。 +func (m *RedisManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) { id := uuidNew() now := time.Now().UTC() pipe := m.rdb.Pipeline() // 写入 meta Hash - pipe.HSet(ctx, metaKey(id), map[string]interface{}{ - "session_id": id, - "config.tts_enabled": strconv.FormatBool(config.TTSEnabled), + meta := map[string]interface{}{ + "session_id": id, + "user_id": userID, + "title": models.DefaultSessionTitle, + "config.tts_enabled": strconv.FormatBool(config.TTSEnabled), "config.detail_level": config.DetailLevel, - "config.language": config.Language, - "created_at": now.Format(time.RFC3339), - "last_active": now.Format(time.RFC3339), - "active_request_id": "", - }) + "config.language": config.Language, + "created_at": now.Format(time.RFC3339), + "updated_at": now.Format(time.RFC3339), + "last_active": now.Format(time.RFC3339), + "active_request_id": "", + } + pipe.HSet(ctx, metaKey(id), meta) pipe.Expire(ctx, metaKey(id), m.ttl) // 初始化空 history List pipe.RPush(ctx, histKey(id), placeholderHistoryMark) pipe.Expire(ctx, histKey(id), m.ttl) + // 如果有 userID,添加到用户会话索引 + if userID != "" { + pipe.SAdd(ctx, userSessKey(userID), id) + pipe.Expire(ctx, userSessKey(userID), m.ttl) + } + if _, err := pipe.Exec(ctx); err != nil { return "", fmt.Errorf("redis create session: %w", err) } - logger.Log.Debugw("redis session created", "session", id) + logger.Log.Debugw("redis session created", "session", id, "user_id", userID) return id, nil } @@ -83,9 +95,12 @@ func (m *RedisManager) Get(ctx context.Context, sessionID string) (*models.Sessi } sess := &models.Session{ - ID: vals["session_id"], + ID: vals["session_id"], + UserID: vals["user_id"], + Title: vals["title"], } sess.CreatedAt, _ = time.Parse(time.RFC3339, vals["created_at"]) + sess.UpdatedAt, _ = time.Parse(time.RFC3339, vals["updated_at"]) sess.Config.TTSEnabled, _ = strconv.ParseBool(vals["config.tts_enabled"]) sess.Config.DetailLevel = vals["config.detail_level"] sess.Config.Language = vals["config.language"] @@ -104,8 +119,10 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch return ErrSessionNotFound } + now := time.Now().UTC().Format(time.RFC3339) fields := map[string]interface{}{ - "last_active": time.Now().UTC().Format(time.RFC3339), + "last_active": now, + "updated_at": now, } if patch.TTSEnabled != nil { fields["config.tts_enabled"] = strconv.FormatBool(*patch.TTSEnabled) @@ -127,6 +144,109 @@ func (m *RedisManager) UpdateConfig(ctx context.Context, sessionID string, patch return nil } +// UpdateTitle 更新会话标题。 +func (m *RedisManager) UpdateTitle(ctx context.Context, sessionID string, title string) error { + exists, err := m.rdb.Exists(ctx, metaKey(sessionID)).Result() + if err != nil { + return fmt.Errorf("redis check session: %w", err) + } + if exists == 0 { + return ErrSessionNotFound + } + + now := time.Now().UTC().Format(time.RFC3339) + if err := m.rdb.HSet(ctx, metaKey(sessionID), "title", title, "updated_at", now, "last_active", now).Err(); err != nil { + return fmt.Errorf("redis update title: %w", err) + } + + m.rdb.Expire(ctx, metaKey(sessionID), m.ttl) + logger.Log.Debugw("redis session title updated", "session", sessionID, "title", title) + return nil +} + +// ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。 +func (m *RedisManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + + // 从用户会话索引获取所有 session ID + sessionIDs, err := m.rdb.SMembers(ctx, userSessKey(userID)).Result() + if err != nil { + return nil, 0, fmt.Errorf("redis list user sessions: %w", err) + } + + // 收集有效的会话摘要 + var list []ConversationSummary + for _, sid := range sessionIDs { + vals, err := m.rdb.HGetAll(ctx, metaKey(sid)).Result() + if err != nil || len(vals) == 0 { + continue + } + + updatedAt, _ := time.Parse(time.RFC3339, vals["updated_at"]) + lastActive, _ := time.Parse(time.RFC3339, vals["last_active"]) + + // 检查是否过期 + if time.Since(lastActive) > m.ttl { + continue + } + + // 获取最后一条消息 + lastMsg := "" + msgCount := 0 + raws, err := m.rdb.LRange(ctx, histKey(sid), 0, 0).Result() + if err == nil && len(raws) > 0 && raws[0] != placeholderHistoryMark { + var msg models.Message + if json.Unmarshal([]byte(raws[0]), &msg) == nil { + lastMsg = msg.Content + } + } + // 获取消息总数(减去占位符) + totalLen, err := m.rdb.LLen(ctx, histKey(sid)).Result() + if err == nil { + msgCount = int(totalLen) + if msgCount > 0 { + msgCount-- // 减去占位符 + } + } + + list = append(list, ConversationSummary{ + ID: vals["session_id"], + Title: vals["title"], + LastMessage: lastMsg, + MessageCount: msgCount, + UpdatedAt: updatedAt, + }) + } + + // 按 UpdatedAt 降序排序 + for i := 0; i < len(list); i++ { + for j := i + 1; j < len(list); j++ { + if list[j].UpdatedAt.After(list[i].UpdatedAt) { + list[i], list[j] = list[j], list[i] + } + } + } + + total := len(list) + + // 分页 + start := (page - 1) * size + if start >= total { + return []ConversationSummary{}, total, nil + } + end := start + size + if end > total { + end = total + } + + return list[start:end], total, nil +} + // GetHistory 获取最近 N 轮对话历史。 func (m *RedisManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) { // 检查会话是否存在 @@ -193,8 +313,18 @@ func (m *RedisManager) AppendMessage(ctx context.Context, sessionID string, msg // 刷新 TTL pipe.Expire(ctx, histKey(sessionID), m.ttl) pipe.Expire(ctx, metaKey(sessionID), m.ttl) - // 更新 last_active - pipe.HSet(ctx, metaKey(sessionID), "last_active", time.Now().UTC().Format(time.RFC3339)) + + now := time.Now().UTC().Format(time.RFC3339) + // 更新 last_active 和 updated_at + pipe.HSet(ctx, metaKey(sessionID), "last_active", now, "updated_at", now) + + // 自动更新标题:首条 user 消息时,如果标题为默认值 + if msg.Role == "user" { + title, _ := m.rdb.HGet(ctx, metaKey(sessionID), "title").Result() + if title == models.DefaultSessionTitle { + pipe.HSet(ctx, metaKey(sessionID), "title", generateTitle(msg.Content)) + } + } if _, err := pipe.Exec(ctx); err != nil { return fmt.Errorf("redis append message: %w", err) @@ -280,6 +410,9 @@ func (m *RedisManager) Touch(ctx context.Context, sessionID string) error { // Destroy 显式销毁会话。 func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error { + // 先获取 user_id 以便清理索引 + userID, _ := m.rdb.HGet(ctx, metaKey(sessionID), "user_id").Result() + deleted, err := m.rdb.Del(ctx, metaKey(sessionID), histKey(sessionID)).Result() if err != nil { return fmt.Errorf("redis destroy session: %w", err) @@ -288,6 +421,11 @@ func (m *RedisManager) Destroy(ctx context.Context, sessionID string) error { return ErrSessionNotFound } + // 清理用户会话索引 + if userID != "" { + m.rdb.SRem(ctx, userSessKey(userID), sessionID) + } + logger.Log.Debugw("redis session destroyed", "session", sessionID) return nil } diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index 5aa2acd..25d0f97 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -114,7 +114,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche defer conn.Close() // 创建会话 - sessionID, err := sessionMgr.Create(context.Background(), models.DefaultConfig()) + sessionID, err := sessionMgr.Create(context.Background(), "", models.DefaultConfig()) if err != nil { logger.Log.Errorw("create session failed", "error", err) return -- 2.49.1 From ec8555d44bd262539aa642ddc5eed5878283dd67 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:36:38 +0800 Subject: [PATCH 28/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20Session=20Ma?= =?UTF-8?q?nager=20=E6=96=B0=E6=96=B9=E6=B3=95=E6=B5=8B=E8=AF=95=EF=BC=88L?= =?UTF-8?q?istByUser=20=E5=88=86=E9=A1=B5=E3=80=81UpdateTitle=E3=80=81?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=A0=87=E9=A2=98=E7=94=9F=E6=88=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/session/memory_test.go | 195 ++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/backend/internal/session/memory_test.go b/backend/internal/session/memory_test.go index eb37b73..ba670f0 100644 --- a/backend/internal/session/memory_test.go +++ b/backend/internal/session/memory_test.go @@ -284,3 +284,198 @@ func TestActiveCount(t *testing.T) { t.Errorf("ActiveCount = %d, want 2", m.ActiveCount()) } } + +func TestCreateWithUserID(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + id, err := m.Create(ctx, "user-123", models.DefaultConfig()) + if err != nil { + t.Fatalf("Create: %v", err) + } + + sess, err := m.Get(ctx, id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if sess.UserID != "user-123" { + t.Errorf("UserID = %q, want %q", sess.UserID, "user-123") + } + if sess.Title != models.DefaultSessionTitle { + t.Errorf("Title = %q, want %q", sess.Title, models.DefaultSessionTitle) + } + if sess.UpdatedAt.IsZero() { + t.Error("UpdatedAt should not be zero") + } +} + +func TestUpdateTitle(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + id, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + + if err := m.UpdateTitle(ctx, id, "自定义标题"); err != nil { + t.Fatalf("UpdateTitle: %v", err) + } + + sess, _ := m.Get(ctx, id) + if sess.Title != "自定义标题" { + t.Errorf("Title = %q, want %q", sess.Title, "自定义标题") + } +} + +func TestUpdateTitleNotFound(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + err := m.UpdateTitle(ctx, "nonexistent", "标题") + if err != ErrSessionNotFound { + t.Errorf("UpdateTitle nonexistent: err = %v, want ErrSessionNotFound", err) + } +} + +func TestAutoTitleOnFirstMessage(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + id, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + + // 首条 user 消息应自动更新标题 + m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "你好世界"}) + + sess, _ := m.Get(ctx, id) + if sess.Title != "你好世界" { + t.Errorf("Title = %q, want %q", sess.Title, "你好世界") + } +} + +func TestAutoTitleLongMessage(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + id, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + + // 超过 20 字符的消息应截断 + longMsg := "这是一条很长很长很长很长很长很长很长很长的消息" + m.AppendMessage(ctx, id, models.Message{Role: "user", Content: longMsg}) + + sess, _ := m.Get(ctx, id) + expected := string([]rune(longMsg)[:20]) + "…" + if sess.Title != expected { + t.Errorf("Title = %q, want %q", sess.Title, expected) + } +} + +func TestAutoTitleNotOverwritten(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + id, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + + // 首条消息设置标题 + m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "第一条消息"}) + // 第二条消息不应覆盖已有的标题 + m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "第二条消息"}) + + sess, _ := m.Get(ctx, id) + if sess.Title != "第一条消息" { + t.Errorf("Title = %q, want %q", sess.Title, "第一条消息") + } +} + +func TestListByUser(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + // 创建两个用户的不同会话 + id1, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + m.AppendMessage(ctx, id1, models.Message{Role: "user", Content: "会话1"}) + id2, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + m.AppendMessage(ctx, id2, models.Message{Role: "user", Content: "会话2"}) + m.Create(ctx, "user-2", models.DefaultConfig()) // 其他用户的会话 + + list, total, err := m.ListByUser(ctx, "user-1", 1, 10) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if total != 2 { + t.Errorf("total = %d, want 2", total) + } + if len(list) != 2 { + t.Fatalf("len = %d, want 2", len(list)) + } + // 按 UpdatedAt 降序,id2 应在前 + if list[0].ID != id2 { + t.Errorf("list[0].ID = %q, want %q", list[0].ID, id2) + } + if list[0].Title != "会话2" { + t.Errorf("list[0].Title = %q, want %q", list[0].Title, "会话2") + } + if list[0].LastMessage != "会话2" { + t.Errorf("list[0].LastMessage = %q, want %q", list[0].LastMessage, "会话2") + } +} + +func TestListByUserPagination(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + // 创建 5 个会话 + for i := 0; i < 5; i++ { + id, _ := m.Create(ctx, "user-1", models.DefaultConfig()) + m.AppendMessage(ctx, id, models.Message{Role: "user", Content: "msg"}) + } + + // 第 1 页,每页 2 条 + list, total, _ := m.ListByUser(ctx, "user-1", 1, 2) + if total != 5 { + t.Errorf("total = %d, want 5", total) + } + if len(list) != 2 { + t.Errorf("page 1 len = %d, want 2", len(list)) + } + + // 第 2 页 + list, _, _ = m.ListByUser(ctx, "user-1", 2, 2) + if len(list) != 2 { + t.Errorf("page 2 len = %d, want 2", len(list)) + } + + // 第 3 页(最后一页) + list, _, _ = m.ListByUser(ctx, "user-1", 3, 2) + if len(list) != 1 { + t.Errorf("page 3 len = %d, want 1", len(list)) + } + + // 超出范围的页 + list, _, _ = m.ListByUser(ctx, "user-1", 10, 2) + if len(list) != 0 { + t.Errorf("out of range page len = %d, want 0", len(list)) + } +} + +func TestListByUserEmpty(t *testing.T) { + m := NewMemoryManager(30*time.Minute, 20) + defer m.Stop() + ctx := context.Background() + + list, total, err := m.ListByUser(ctx, "no-such-user", 1, 10) + if err != nil { + t.Fatalf("ListByUser: %v", err) + } + if total != 0 { + t.Errorf("total = %d, want 0", total) + } + if len(list) != 0 { + t.Errorf("len = %d, want 0", len(list)) + } +} -- 2.49.1 From 62baa656ee24fa41dbd6d8656d605ad71f4d50cb Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:39:20 +0800 Subject: [PATCH 29/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Conversation?= =?UTF-8?q?Handler=EF=BC=88=E5=AF=B9=E8=AF=9D=20CRUD=20+=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=9F=A5=E8=AF=A2=20+=20=E6=9D=83=E9=99=90=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20+=20=E8=B7=AF=E7=94=B1=E6=B3=A8=E5=86=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List: GET /api/conversations 获取当前用户对话列表(分页) - Create: POST /api/conversations 创建新对话 - Get: GET /api/conversations/:id 获取对话详情 - UpdateTitle: PATCH /api/conversations/:id 更新对话标题 - Delete: DELETE /api/conversations/:id 删除对话 - GetMessages: GET /api/conversations/:id/messages 获取消息列表 - 所有端点通过 AuthMiddleware 认证 - getSessionForUser 校验 session.UserID == claims.UserID - 返回 404 而非 403 避免信息泄露 --- backend/internal/api/conversation.go | 300 +++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 backend/internal/api/conversation.go diff --git a/backend/internal/api/conversation.go b/backend/internal/api/conversation.go new file mode 100644 index 0000000..f95e2c1 --- /dev/null +++ b/backend/internal/api/conversation.go @@ -0,0 +1,300 @@ +// Package api 提供 REST API 处理函数。 +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/hhs/camtalk/internal/auth" + apperr "github.com/hhs/camtalk/internal/errors" + "github.com/hhs/camtalk/internal/models" + "github.com/hhs/camtalk/internal/session" +) + +// ConversationHandler 提供对话相关的 REST 端点。 +type ConversationHandler struct { + sessionMgr session.Manager + tokenMgr *auth.TokenManager +} + +// NewConversationHandler 创建 ConversationHandler。 +func NewConversationHandler(sessionMgr session.Manager, tokenMgr *auth.TokenManager) *ConversationHandler { + return &ConversationHandler{ + sessionMgr: sessionMgr, + tokenMgr: tokenMgr, + } +} + +// RegisterRoutes 注册对话相关路由到给定的路由组。所有端点需要认证。 +func (h *ConversationHandler) RegisterRoutes(rg *gin.RouterGroup) { + conv := rg.Group("/conversations", auth.AuthMiddleware(h.tokenMgr)) + { + conv.GET("", h.List) + conv.POST("", h.Create) + conv.GET("/:id", h.Get) + conv.PATCH("/:id", h.UpdateTitle) + conv.DELETE("/:id", h.Delete) + conv.GET("/:id/messages", h.GetMessages) + } +} + +// List GET /api/conversations — 获取当前用户的对话列表。 +func (h *ConversationHandler) List(c *gin.Context) { + userID := c.GetString(auth.ContextKeyUserID) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + + if page <= 0 { + page = 1 + } + if size <= 0 || size > 100 { + size = 20 + } + + summaries, total, err := h.sessionMgr.ListByUser(c.Request.Context(), userID, page, size) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to list conversations", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "conversations": summaries, + "total": total, + "page": page, + "size": size, + }) +} + +// CreateConversationRequest POST /api/conversations 请求体。 +type CreateConversationRequest struct { + Config *models.SessionConfig `json:"config,omitempty"` +} + +// Create POST /api/conversations — 创建新对话。 +func (h *ConversationHandler) Create(c *gin.Context) { + userID := c.GetString(auth.ContextKeyUserID) + + var req CreateConversationRequest + _ = c.ShouldBindJSON(&req) + + cfg := models.DefaultConfig() + if req.Config != nil { + cfg = *req.Config + } + + sessionID, err := h.sessionMgr.Create(c.Request.Context(), userID, cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to create conversation", + }) + return + } + + sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to retrieve created conversation", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": sess.ID, + "title": sess.Title, + "created_at": sess.CreatedAt, + "updated_at": sess.UpdatedAt, + }) +} + +// Get GET /api/conversations/:id — 获取对话详情。 +func (h *ConversationHandler) Get(c *gin.Context) { + sessionID := c.Param("id") + + sess, err := h.getSessionForUser(c, sessionID) + if err != nil { + return // getSessionForUser 已写入响应 + } + + c.JSON(http.StatusOK, gin.H{ + "id": sess.ID, + "title": sess.Title, + "created_at": sess.CreatedAt, + "updated_at": sess.UpdatedAt, + "config": sess.Config, + }) +} + +// UpdateTitleRequest PATCH /api/conversations/:id 请求体。 +type UpdateTitleRequest struct { + Title string `json:"title"` +} + +// UpdateTitle PATCH /api/conversations/:id — 更新对话标题。 +func (h *ConversationHandler) UpdateTitle(c *gin.Context) { + sessionID := c.Param("id") + + // 先校验归属 + if _, err := h.getSessionForUser(c, sessionID); err != nil { + return + } + + var req UpdateTitleRequest + if err := c.ShouldBindJSON(&req); err != nil || req.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "title is required", + }) + return + } + + if len([]rune(req.Title)) > 100 { + c.JSON(http.StatusBadRequest, gin.H{ + "code": apperr.CodeInvalidInput, + "message": "title must be 100 characters or less", + }) + return + } + + if err := h.sessionMgr.UpdateTitle(c.Request.Context(), sessionID, req.Title); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "code": apperr.CodeSessionNotFound, + "message": "conversation not found", + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to update title", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "title updated", + }) +} + +// Delete DELETE /api/conversations/:id — 删除对话。 +func (h *ConversationHandler) Delete(c *gin.Context) { + sessionID := c.Param("id") + + // 先校验归属 + if _, err := h.getSessionForUser(c, sessionID); err != nil { + return + } + + if err := h.sessionMgr.Destroy(c.Request.Context(), sessionID); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "code": apperr.CodeSessionNotFound, + "message": "conversation not found", + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to delete conversation", + }) + return + } + + c.Status(http.StatusNoContent) +} + +// GetMessages GET /api/conversations/:id/messages — 获取对话消息列表。 +// +// 查询参数: +// - limit: 返回消息数量上限,默认 50 +// - before: 消息偏移量(用于分页),返回此偏移量之前的消息 +func (h *ConversationHandler) GetMessages(c *gin.Context) { + sessionID := c.Param("id") + + // 先校验归属 + if _, err := h.getSessionForUser(c, sessionID); err != nil { + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 200 { + limit = 50 + } + + before, _ := strconv.Atoi(c.DefaultQuery("before", "0")) + + // 获取全量历史(内存实现中 history 是全量存储的) + allMessages, err := h.sessionMgr.GetHistory(c.Request.Context(), sessionID, 0) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "code": apperr.CodeSessionNotFound, + "message": "conversation not found", + }) + return + } + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to get messages", + }) + return + } + + total := len(allMessages) + + // before > 0 表示取 before 之前的消息(不含 before 位置) + if before > 0 && before <= total { + allMessages = allMessages[:before] + } + + // 取最后 limit 条 + start := len(allMessages) - limit + if start < 0 { + start = 0 + } + messages := allMessages[start:] + + c.JSON(http.StatusOK, gin.H{ + "messages": messages, + "total": total, + }) +} + +// getSessionForUser 获取会话并校验当前用户是否有权限访问。 +// 返回 404(而非 403)以避免信息泄露。 +func (h *ConversationHandler) getSessionForUser(c *gin.Context, sessionID string) (*models.Session, error) { + sess, err := h.sessionMgr.Get(c.Request.Context(), sessionID) + if err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + c.JSON(http.StatusNotFound, gin.H{ + "code": apperr.CodeSessionNotFound, + "message": "conversation not found", + }) + } else { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "internal server error", + }) + } + return nil, err + } + + userID := c.GetString(auth.ContextKeyUserID) + if sess.UserID != userID { + c.JSON(http.StatusNotFound, gin.H{ + "code": apperr.CodeSessionNotFound, + "message": "conversation not found", + }) + return nil, errors.New("forbidden") + } + + return sess, nil +} -- 2.49.1 From f902e05e31753608a965ac78b0f68ebe3d12f263 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:39:39 +0800 Subject: [PATCH 30/46] =?UTF-8?q?feat:=20main.go=20=E6=8E=A5=E5=85=A5=20Co?= =?UTF-8?q?nversationHandler=20=E8=B7=AF=E7=94=B1=E6=B3=A8=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9bc51b0..6100c19 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -140,6 +140,10 @@ func main() { authHandler := api.NewAuthHandler(authService, tokenMgr) authHandler.RegisterRoutes(apiGroup) + // Conversation REST 端点 + convHandler := api.NewConversationHandler(sessionMgr, tokenMgr) + convHandler.RegisterRoutes(apiGroup) + // WebSocket r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg)) -- 2.49.1 From d01aeaba68f591c41d2fc4d75579c1b7e72ab8b3 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:41:28 +0800 Subject: [PATCH 31/46] =?UTF-8?q?feat:=20=E7=BC=96=E5=86=99=20Conversation?= =?UTF-8?q?Handler=20API=20=E6=B5=8B=E8=AF=95=EF=BC=88httptest=20+=20mock?= =?UTF-8?q?=20SessionManager=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List: 成功、分页参数、未认证 401 - Create: 成功、自定义配置 - Get: 成功、404 未找到、404 权限不足(隐藏信息) - UpdateTitle: 成功、空标题校验、超长标题校验 - Delete: 成功、权限不足 - GetMessages: 成功、limit 分页、before 偏移分页、权限不足 - 共 17 个 Conversation 测试用例,全部通过 --- backend/internal/api/conversation_test.go | 575 ++++++++++++++++++++++ 1 file changed, 575 insertions(+) create mode 100644 backend/internal/api/conversation_test.go diff --git a/backend/internal/api/conversation_test.go b/backend/internal/api/conversation_test.go new file mode 100644 index 0000000..6b873fc --- /dev/null +++ b/backend/internal/api/conversation_test.go @@ -0,0 +1,575 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/hhs/camtalk/internal/api" + "github.com/hhs/camtalk/internal/auth" + "github.com/hhs/camtalk/internal/models" + "github.com/hhs/camtalk/internal/session" +) + +// mockSessionManager 实现 session.Manager 接口,用于 ConversationHandler 测试。 +type mockSessionManager struct { + CreateFunc func(ctx context.Context, userID string, config models.SessionConfig) (string, error) + GetFunc func(ctx context.Context, sessionID string) (*models.Session, error) + UpdateConfigFunc func(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error + UpdateTitleFunc func(ctx context.Context, sessionID string, title string) error + ListByUserFunc func(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) + GetHistoryFunc func(ctx context.Context, sessionID string, limit int) ([]models.Message, error) + AppendMessageFunc func(ctx context.Context, sessionID string, msg models.Message) error + SetActiveRequestFunc func(ctx context.Context, sessionID string, requestID string) error + GetActiveRequestIDFunc func(ctx context.Context, sessionID string) (string, error) + ClearActiveRequestFunc func(ctx context.Context, sessionID string) error + TouchFunc func(ctx context.Context, sessionID string) error + DestroyFunc func(ctx context.Context, sessionID string) error + ActiveCountFunc func() int +} + +func (m *mockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) { + return m.CreateFunc(ctx, userID, config) +} + +func (m *mockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) { + return m.GetFunc(ctx, sessionID) +} + +func (m *mockSessionManager) UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error { + return m.UpdateConfigFunc(ctx, sessionID, patch) +} + +func (m *mockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error { + return m.UpdateTitleFunc(ctx, sessionID, title) +} + +func (m *mockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) { + return m.ListByUserFunc(ctx, userID, page, size) +} + +func (m *mockSessionManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) { + return m.GetHistoryFunc(ctx, sessionID, limit) +} + +func (m *mockSessionManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error { + return m.AppendMessageFunc(ctx, sessionID, msg) +} + +func (m *mockSessionManager) SetActiveRequest(ctx context.Context, sessionID string, requestID string) error { + return m.SetActiveRequestFunc(ctx, sessionID, requestID) +} + +func (m *mockSessionManager) GetActiveRequestID(ctx context.Context, sessionID string) (string, error) { + return m.GetActiveRequestIDFunc(ctx, sessionID) +} + +func (m *mockSessionManager) ClearActiveRequest(ctx context.Context, sessionID string) error { + return m.ClearActiveRequestFunc(ctx, sessionID) +} + +func (m *mockSessionManager) Touch(ctx context.Context, sessionID string) error { + return m.TouchFunc(ctx, sessionID) +} + +func (m *mockSessionManager) Destroy(ctx context.Context, sessionID string) error { + return m.DestroyFunc(ctx, sessionID) +} + +func (m *mockSessionManager) ActiveCount() int { + return m.ActiveCountFunc() +} + +// newConvTestRouter 创建带 ConversationHandler 路由的测试引擎,同时返回 TokenManager。 +func newConvTestRouter(mgr session.Manager) (*gin.Engine, *auth.TokenManager) { + gin.SetMode(gin.TestMode) + r := gin.New() + tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + h := api.NewConversationHandler(mgr, tm) + h.RegisterRoutes(r.Group("/api")) + return r, tm +} + +// --- List --- + +func TestConversationList_Success(t *testing.T) { + now := time.Now() + mgr := &mockSessionManager{ + ListByUserFunc: func(_ context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) { + assert.Equal(t, "user-123", userID) + assert.Equal(t, 1, page) + assert.Equal(t, 20, size) + return []session.ConversationSummary{ + {ID: "sess-1", Title: "对话一", MessageCount: 3, UpdatedAt: now}, + {ID: "sess-2", Title: "对话二", MessageCount: 1, UpdatedAt: now.Add(-time.Hour)}, + }, 2, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, float64(2), resp["total"]) + convs := resp["conversations"].([]interface{}) + assert.Len(t, convs, 2) +} + +func TestConversationList_WithPagination(t *testing.T) { + mgr := &mockSessionManager{ + ListByUserFunc: func(_ context.Context, _ string, page, size int) ([]session.ConversationSummary, int, error) { + assert.Equal(t, 2, page) + assert.Equal(t, 10, size) + return []session.ConversationSummary{}, 0, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations?page=2&size=10", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestConversationList_MissingAuth(t *testing.T) { + mgr := &mockSessionManager{} + r, _ := newConvTestRouter(mgr) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// --- Create --- + +func TestConversationCreate_Success(t *testing.T) { + createdID := "new-session-id" + now := time.Now() + mgr := &mockSessionManager{ + CreateFunc: func(_ context.Context, userID string, cfg models.SessionConfig) (string, error) { + assert.Equal(t, "user-123", userID) + return createdID, nil + }, + GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) { + assert.Equal(t, createdID, sessionID) + return &models.Session{ + ID: createdID, + UserID: "user-123", + Title: models.DefaultSessionTitle, + CreatedAt: now, + UpdatedAt: now, + Config: models.DefaultConfig(), + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, createdID, resp["id"]) + assert.Equal(t, models.DefaultSessionTitle, resp["title"]) +} + +func TestConversationCreate_WithConfig(t *testing.T) { + mgr := &mockSessionManager{ + CreateFunc: func(_ context.Context, _ string, cfg models.SessionConfig) (string, error) { + assert.False(t, cfg.TTSEnabled) + assert.Equal(t, "high", cfg.DetailLevel) + return "sess-1", nil + }, + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ + ID: "sess-1", + UserID: "user-123", + Title: models.DefaultSessionTitle, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + body, _ := json.Marshal(api.CreateConversationRequest{ + Config: &models.SessionConfig{TTSEnabled: false, DetailLevel: "high", Language: "zh-CN"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/conversations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + +// --- Get --- + +func TestConversationGet_Success(t *testing.T) { + now := time.Now() + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) { + assert.Equal(t, "sess-1", sessionID) + return &models.Session{ + ID: "sess-1", + UserID: "user-123", + Title: "我的对话", + CreatedAt: now, + UpdatedAt: now, + Config: models.DefaultConfig(), + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "我的对话", resp["title"]) +} + +func TestConversationGet_NotFound(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return nil, session.ErrSessionNotFound + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/nonexistent", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND") +} + +func TestConversationGet_Forbidden(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + // 会话属于另一个用户 + return &models.Session{ + ID: "sess-1", + UserID: "other-user", + Title: "他人对话", + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + // 返回 404 而非 403,避免信息泄露 + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND") +} + +// --- UpdateTitle --- + +func TestConversationUpdateTitle_Success(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + UpdateTitleFunc: func(_ context.Context, sessionID, title string) error { + assert.Equal(t, "sess-1", sessionID) + assert.Equal(t, "新标题", title) + return nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + body, _ := json.Marshal(api.UpdateTitleRequest{Title: "新标题"}) + req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "title updated") +} + +func TestConversationUpdateTitle_EmptyTitle(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + body, _ := json.Marshal(api.UpdateTitleRequest{Title: ""}) + req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "title is required") +} + +func TestConversationUpdateTitle_TooLong(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + longTitle := "" + for i := 0; i < 101; i++ { + longTitle += "测" + } + body, _ := json.Marshal(api.UpdateTitleRequest{Title: longTitle}) + req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "title must be 100 characters or less") +} + +// --- Delete --- + +func TestConversationDelete_Success(t *testing.T) { + destroyCalled := false + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + DestroyFunc: func(_ context.Context, sessionID string) error { + assert.Equal(t, "sess-1", sessionID) + destroyCalled = true + return nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNoContent, w.Code) + assert.True(t, destroyCalled) +} + +func TestConversationDelete_Forbidden(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "other-user"}, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// --- GetMessages --- + +func TestConversationGetMessages_Success(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + GetHistoryFunc: func(_ context.Context, sessionID string, limit int) ([]models.Message, error) { + assert.Equal(t, "sess-1", sessionID) + assert.Equal(t, 0, limit) // 获取全量 + return []models.Message{ + {Role: "user", Content: "你好"}, + {Role: "assistant", Content: "你好!有什么可以帮助你的吗?"}, + {Role: "user", Content: "今天天气怎么样?"}, + {Role: "assistant", Content: "今天天气不错!"}, + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, float64(4), resp["total"]) + msgs := resp["messages"].([]interface{}) + assert.Len(t, msgs, 4) +} + +func TestConversationGetMessages_WithLimit(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) { + return []models.Message{ + {Role: "user", Content: "消息1"}, + {Role: "assistant", Content: "回复1"}, + {Role: "user", Content: "消息2"}, + {Role: "assistant", Content: "回复2"}, + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?limit=2", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + msgs := resp["messages"].([]interface{}) + assert.Len(t, msgs, 2) +} + +func TestConversationGetMessages_WithBefore(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "user-123"}, nil + }, + GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) { + return []models.Message{ + {Role: "user", Content: "消息1"}, + {Role: "assistant", Content: "回复1"}, + {Role: "user", Content: "消息2"}, + {Role: "assistant", Content: "回复2"}, + }, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?before=2&limit=10", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + // before=2 表示取 index 0..1,共 2 条 + msgs := resp["messages"].([]interface{}) + assert.Len(t, msgs, 2) +} + +func TestConversationGetMessages_Forbidden(t *testing.T) { + mgr := &mockSessionManager{ + GetFunc: func(_ context.Context, _ string) (*models.Session, error) { + return &models.Session{ID: "sess-1", UserID: "other-user"}, nil + }, + } + r, tm := newConvTestRouter(mgr) + + access, _, err := tm.GeneratePair("user-123", "alice") + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil) + req.Header.Set("Authorization", "Bearer "+access) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} -- 2.49.1 From 2aa3c98ab6251edfdd838d739b43c92e916495fd Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:46:11 +0800 Subject: [PATCH 32/46] =?UTF-8?q?feat:=20Phase=207.1=20=E2=80=94=20?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=20ServeWS=20=E7=AD=BE=E5=90=8D=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20tokenMgr=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServeWS 和 serveWS 函数新增 *auth.TokenManager 参数 - main.go 传入 tokenMgr 到 ServeWS - handler_test.go 适配新签名 --- backend/cmd/server/main.go | 2 +- backend/internal/ws/handler.go | 7 ++++--- backend/internal/ws/handler_test.go | 4 +++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6100c19..2a6275b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -145,7 +145,7 @@ func main() { convHandler.RegisterRoutes(apiGroup) // WebSocket - r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg)) + r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr)) // HTTP Server srv := &http.Server{ diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index 25d0f97..7274b68 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "github.com/hhs/camtalk/internal/auth" "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/errors" "github.com/hhs/camtalk/internal/logger" @@ -91,7 +92,7 @@ func (w *WSClient) SendError(err models.WsError) error { } // ServeWS 处理 WebSocket 升级请求。 -func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config) gin.HandlerFunc { +func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *config.Config, tokenMgr *auth.TokenManager) gin.HandlerFunc { upgrader := newUpgrader(cfg) heartbeatInterval := time.Duration(cfg.Server.HeartbeatInterval) * time.Second heartbeatTimeout := time.Duration(cfg.Server.HeartbeatTimeout) * time.Second @@ -100,12 +101,12 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co maxHistory := cfg.Session.MaxHistory return func(c *gin.Context) { - serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory) + serveWS(c, sessionMgr, orch, upgrader, heartbeatInterval, heartbeatTimeout, version, maxHistory, tokenMgr) } } func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator, - upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int) { + upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) { conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Log.Errorw("websocket upgrade failed", "error", err) diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go index 2872d5c..7fc43cb 100644 --- a/backend/internal/ws/handler_test.go +++ b/backend/internal/ws/handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" "context" + "github.com/hhs/camtalk/internal/auth" "github.com/hhs/camtalk/internal/config" "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" @@ -144,7 +145,8 @@ func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Se Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60}, Session: config.SessionConfig{MaxHistory: 20}, } - r.GET("/ws", ServeWS(sessionMgr, orch, cfg)) + tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr)) srv := httptest.NewServer(r) -- 2.49.1 From 905b56640e463ded593f1155908cf014446b908c Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:47:05 +0800 Subject: [PATCH 33/46] =?UTF-8?q?feat:=20Phase=207.2=20=E2=80=94=20WS=20?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=20JWT=20=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 ?token=xxx 查询参数提取 access_token - 校验失败返回 401(missing token / invalid token) - 校验成功后将 userID 用于创建会话 - 更新现有测试:setupTestServer 自动生成有效 token --- backend/internal/ws/handler.go | 19 +++++++++++++++++-- backend/internal/ws/handler_test.go | 10 +++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index 7274b68..8b98e38 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -107,6 +107,21 @@ func ServeWS(sessionMgr session.Manager, orch orchestrator.Orchestrator, cfg *co func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orchestrator, upgrader websocket.Upgrader, heartbeatInterval, heartbeatTimeout time.Duration, version string, maxHistory int, tokenMgr *auth.TokenManager) { + + // --- JWT 认证(upgrade 前完成,失败直接返回 HTTP 错误) --- + token := c.Query("token") + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := tokenMgr.ValidateAccess(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + userID := claims.UserID + username := claims.Username + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Log.Errorw("websocket upgrade failed", "error", err) @@ -115,7 +130,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche defer conn.Close() // 创建会话 - sessionID, err := sessionMgr.Create(context.Background(), "", models.DefaultConfig()) + sessionID, err := sessionMgr.Create(context.Background(), userID, models.DefaultConfig()) if err != nil { logger.Log.Errorw("create session failed", "error", err) return @@ -135,7 +150,7 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche SessionID: sessionID, ServerVersion: version, }) - logger.Log.Infow("client connected", "session", sessionID) + logger.Log.Infow("client connected", "session", sessionID, "user_id", userID, "username", username) // 心跳检测 lastPong := time.Now() diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go index 7fc43cb..585acba 100644 --- a/backend/internal/ws/handler_test.go +++ b/backend/internal/ws/handler_test.go @@ -133,25 +133,29 @@ func (m *MockOrchestrator) ProcessQuery( // --- 测试辅助函数 --- // setupTestServer 创建测试用 Gin 服务器和 WebSocket URL。 +// 返回的 wsURL 已包含有效 token,可直接连接。 func setupTestServer(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, string) { t.Helper() sessionMgr := session.NewMemoryManager(5*time.Minute, 20) t.Cleanup(func() { sessionMgr.Stop() }) + tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + r := gin.New() cfg := &config.Config{ App: config.AppConfig{Version: "test"}, Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60}, Session: config.SessionConfig{MaxHistory: 20}, } - tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr)) srv := httptest.NewServer(r) - // 构造 WebSocket URL - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + // 生成有效 token 并构造 WebSocket URL + token, _, err := tokenMgr.GeneratePair("test-user", "testuser") + require.NoError(t, err) + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token return srv, wsURL } -- 2.49.1 From 80c6b1b56e3e763989bf596167c3d4e76c6142cc Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:47:34 +0800 Subject: [PATCH 34/46] =?UTF-8?q?feat:=20Phase=207.3=20=E2=80=94=20WS=20co?= =?UTF-8?q?nversation=5Fid=20=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ?conversation_id=xxx 存在时校验 session 归属(UserID 匹配) - 校验失败返回 401 SESSION_NOT_FOUND - 校验通过则复用已有 session;否则创建新 session --- backend/internal/ws/handler.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/backend/internal/ws/handler.go b/backend/internal/ws/handler.go index 8b98e38..feff0ac 100644 --- a/backend/internal/ws/handler.go +++ b/backend/internal/ws/handler.go @@ -122,6 +122,16 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche userID := claims.UserID username := claims.Username + // --- conversation_id 处理(upgrade 前校验归属) --- + conversationID := c.Query("conversation_id") + if conversationID != "" { + sess, err := sessionMgr.Get(c.Request.Context(), conversationID) + if err != nil || sess.UserID != userID { + c.JSON(http.StatusUnauthorized, gin.H{"error": "SESSION_NOT_FOUND"}) + return + } + } + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Log.Errorw("websocket upgrade failed", "error", err) @@ -129,11 +139,17 @@ func serveWS(c *gin.Context, sessionMgr session.Manager, orch orchestrator.Orche } defer conn.Close() - // 创建会话 - sessionID, err := sessionMgr.Create(context.Background(), userID, models.DefaultConfig()) - if err != nil { - logger.Log.Errorw("create session failed", "error", err) - return + // 创建或复用会话 + var sessionID string + if conversationID != "" { + sessionID = conversationID + logger.Log.Infow("resuming conversation", "session", sessionID, "user_id", userID) + } else { + sessionID, err = sessionMgr.Create(context.Background(), userID, models.DefaultConfig()) + if err != nil { + logger.Log.Errorw("create session failed", "error", err) + return + } } client := &Client{ -- 2.49.1 From 3c5c4943e8436974fe84d47fae178081f639c1c1 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:48:47 +0800 Subject: [PATCH 35/46] =?UTF-8?q?feat:=20Phase=207.7=20=E2=80=94=20?= =?UTF-8?q?=E7=BC=96=E5=86=99=20WS=20=E8=AE=A4=E8=AF=81=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestWS_AuthMissingToken: 无 token 返回 401 - TestWS_AuthInvalidToken: 无效 token 返回 401 - TestWS_AuthExpiredToken: 过期 token 返回 401 - TestWS_AuthValidToken: 有效 token 成功连接 - TestWS_AuthConversationIDResume: conversation_id 恢复已有对话 - TestWS_AuthConversationIDNotFound: 不存在的 conversation_id 返回 401 - TestWS_AuthConversationIDOwnership: 非 owner 访问返回 401 --- backend/internal/ws/handler_test.go | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/backend/internal/ws/handler_test.go b/backend/internal/ws/handler_test.go index 585acba..69f1207 100644 --- a/backend/internal/ws/handler_test.go +++ b/backend/internal/ws/handler_test.go @@ -2,6 +2,7 @@ package ws import ( "encoding/base64" + "net/http" "net/http/httptest" "strings" "testing" @@ -573,3 +574,156 @@ func TestWS_QueryWithTTSDisabled(t *testing.T) { err = conn.ReadJSON(&extra) assert.Error(t, err, "不应有额外消息") } + +// --- 认证测试辅助 --- + +// setupTestServerEx 创建测试服务器,返回 tokenMgr 和 sessionMgr 以便测试控制。 +func setupTestServerEx(t *testing.T, orch orchestrator.Orchestrator) (*httptest.Server, *auth.TokenManager, *session.MemoryManager) { + t.Helper() + + sessionMgr := session.NewMemoryManager(5*time.Minute, 20) + t.Cleanup(func() { sessionMgr.Stop() }) + + tokenMgr := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) + + r := gin.New() + cfg := &config.Config{ + App: config.AppConfig{Version: "test"}, + Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60}, + Session: config.SessionConfig{MaxHistory: 20}, + } + r.GET("/ws", ServeWS(sessionMgr, orch, cfg, tokenMgr)) + + srv := httptest.NewServer(r) + return srv, tokenMgr, sessionMgr +} + +// httpGet 发送 HTTP GET 并返回状态码。 +func httpGet(t *testing.T, url string) int { + t.Helper() + resp, err := http.Get(url) + require.NoError(t, err) + resp.Body.Close() + return resp.StatusCode +} + +// --- 认证测试用例 --- + +// TestWS_AuthMissingToken 验证无 token 时返回 401。 +func TestWS_AuthMissingToken(t *testing.T) { + srv, _, _ := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + httpURL := srv.URL + "/ws" + status := httpGet(t, httpURL) + assert.Equal(t, http.StatusUnauthorized, status) +} + +// TestWS_AuthInvalidToken 验证无效 token 时返回 401。 +func TestWS_AuthInvalidToken(t *testing.T) { + srv, _, _ := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + httpURL := srv.URL + "/ws?token=invalid-token" + status := httpGet(t, httpURL) + assert.Equal(t, http.StatusUnauthorized, status) +} + +// TestWS_AuthExpiredToken 验证过期 token 时返回 401。 +func TestWS_AuthExpiredToken(t *testing.T) { + // 创建一个 access TTL 极短的 tokenMgr + sessionMgr := session.NewMemoryManager(5*time.Minute, 20) + defer sessionMgr.Stop() + + tokenMgr := auth.NewTokenManager("test-secret", -1*time.Minute, 7*24*time.Hour) // 已过期 + + r := gin.New() + cfg := &config.Config{ + App: config.AppConfig{Version: "test"}, + Server: config.ServerConfig{HeartbeatInterval: 30, HeartbeatTimeout: 60}, + Session: config.SessionConfig{MaxHistory: 20}, + } + r.GET("/ws", ServeWS(sessionMgr, &MockOrchestrator{}, cfg, tokenMgr)) + srv := httptest.NewServer(r) + defer srv.Close() + + token, _, err := tokenMgr.GeneratePair("test-user", "testuser") + require.NoError(t, err) + + httpURL := srv.URL + "/ws?token=" + token + status := httpGet(t, httpURL) + assert.Equal(t, http.StatusUnauthorized, status) +} + +// TestWS_AuthValidToken 验证有效 token 能成功建立 WS 连接。 +func TestWS_AuthValidToken(t *testing.T) { + srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + token, _, err := tokenMgr.GeneratePair("user-1", "alice") + require.NoError(t, err) + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws?token=" + token + conn := connectWS(t, wsURL) + + msg := readJSON(t, conn) + assert.Equal(t, "connected", msg["type"]) + assert.NotEmpty(t, msg["session_id"]) +} + +// TestWS_AuthConversationIDResume 验证通过 conversation_id 恢复已有对话。 +func TestWS_AuthConversationIDResume(t *testing.T) { + srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + userID := "user-1" + + // 先创建一个属于该用户的 session + ctx := context.Background() + sessionID, err := sessionMgr.Create(ctx, userID, models.DefaultConfig()) + require.NoError(t, err) + + token, _, err := tokenMgr.GeneratePair(userID, "alice") + require.NoError(t, err) + + // 带 conversation_id 连接 + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + "/ws?token=" + token + "&conversation_id=" + sessionID + conn := connectWS(t, wsURL) + + msg := readJSON(t, conn) + assert.Equal(t, "connected", msg["type"]) + assert.Equal(t, sessionID, msg["session_id"], "应复用已有 session") +} + +// TestWS_AuthConversationIDNotFound 验证 conversation_id 不存在时返回 401。 +func TestWS_AuthConversationIDNotFound(t *testing.T) { + srv, tokenMgr, _ := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + token, _, err := tokenMgr.GeneratePair("user-1", "alice") + require.NoError(t, err) + + httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=nonexistent-id" + status := httpGet(t, httpURL) + assert.Equal(t, http.StatusUnauthorized, status) +} + +// TestWS_AuthConversationIDOwnership 验证 conversation_id 不属于当前用户时返回 401。 +func TestWS_AuthConversationIDOwnership(t *testing.T) { + srv, tokenMgr, sessionMgr := setupTestServerEx(t, &MockOrchestrator{}) + defer srv.Close() + + ctx := context.Background() + // user-A 创建 session + sessionID, err := sessionMgr.Create(ctx, "user-A", models.DefaultConfig()) + require.NoError(t, err) + + // user-B 尝试连接该 session + token, _, err := tokenMgr.GeneratePair("user-B", "bob") + require.NoError(t, err) + + httpURL := srv.URL + "/ws?token=" + token + "&conversation_id=" + sessionID + status := httpGet(t, httpURL) + assert.Equal(t, http.StatusUnauthorized, status, "非 owner 访问应返回 401") +} -- 2.49.1 From dae5722945d100832167fe2d7786bbbd6e9979a1 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:52:21 +0800 Subject: [PATCH 36/46] =?UTF-8?q?feat:=20Phase=208.1=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=20MessageRepository=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=92=8C=E6=B6=88=E6=81=AF=E8=A1=A8=E8=BF=81=E7=A7=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/message.go | 40 ++++++++++++++++++++++++ backend/migrations/002_messages.down.sql | 1 + backend/migrations/002_messages.up.sql | 17 ++++++++++ 3 files changed, 58 insertions(+) create mode 100644 backend/internal/store/message.go create mode 100644 backend/migrations/002_messages.down.sql create mode 100644 backend/migrations/002_messages.up.sql diff --git a/backend/internal/store/message.go b/backend/internal/store/message.go new file mode 100644 index 0000000..629af20 --- /dev/null +++ b/backend/internal/store/message.go @@ -0,0 +1,40 @@ +package store + +import ( + "context" + "errors" + "time" + + "github.com/hhs/camtalk/internal/models" +) + +var ( + // ErrMessageNotFound 消息不存在。 + ErrMessageNotFound = errors.New("message not found") +) + +// MessageRepository 消息持久化接口。 +type MessageRepository interface { + // SaveMessage 保存一条消息。 + SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error + + // GetMessages 获取会话的消息列表(分页,按 created_at 升序)。 + // beforeID 为 0 时从最新开始查询。 + GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) + + // GetLastMessage 获取会话的最后一条消息。 + GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) + + // GetMessageCount 获取会话的消息总数。 + GetMessageCount(ctx context.Context, sessionID string) (int, error) +} + +// StoredMessage 持久化消息模型(store 层)。 +type StoredMessage struct { + ID int64 `json:"id"` + SessionID string `json:"-"` + Role string `json:"role"` + Content string `json:"content"` + TokensUsed int `json:"tokens_used"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/backend/migrations/002_messages.down.sql b/backend/migrations/002_messages.down.sql new file mode 100644 index 0000000..cbe8189 --- /dev/null +++ b/backend/migrations/002_messages.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS messages; diff --git a/backend/migrations/002_messages.up.sql b/backend/migrations/002_messages.up.sql new file mode 100644 index 0000000..fe1e088 --- /dev/null +++ b/backend/migrations/002_messages.up.sql @@ -0,0 +1,17 @@ +-- 消息表 +CREATE TABLE IF NOT EXISTS messages ( + id BIGSERIAL PRIMARY KEY, + session_id UUID NOT NULL, + role VARCHAR(16) NOT NULL, -- "user" | "assistant" | "system" + content TEXT NOT NULL, + tokens_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 按会话查询消息(分页核心索引) +CREATE INDEX IF NOT EXISTS idx_messages_session_id_created_at + ON messages(session_id, created_at); + +-- 按会话查询最后一条消息 +CREATE INDEX IF NOT EXISTS idx_messages_session_id_id_desc + ON messages(session_id, id DESC); -- 2.49.1 From 96f4bc7abb65310b439dc286ee80d1cd7322f6db Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:53:14 +0800 Subject: [PATCH 37/46] =?UTF-8?q?feat:=20Phase=208.2=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20PostgreSQL=20MessageRepository?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/store/message_pg.go | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 backend/internal/store/message_pg.go diff --git a/backend/internal/store/message_pg.go b/backend/internal/store/message_pg.go new file mode 100644 index 0000000..8c1512a --- /dev/null +++ b/backend/internal/store/message_pg.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/hhs/camtalk/internal/models" +) + +// PgMessageRepository 基于 PostgreSQL 的 MessageRepository 实现。 +type PgMessageRepository struct { + pool *pgxpool.Pool +} + +// NewPgMessageRepository 创建 PgMessageRepository。 +func NewPgMessageRepository(pool *pgxpool.Pool) *PgMessageRepository { + return &PgMessageRepository{pool: pool} +} + +func (r *PgMessageRepository) SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO messages (session_id, role, content, tokens_used) VALUES ($1, $2, $3, $4)`, + sessionID, msg.Role, msg.Content, tokensUsed, + ) + return err +} + +func (r *PgMessageRepository) GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error) { + if limit <= 0 { + limit = 50 + } + + var rows []StoredMessage + var err error + + if beforeID > 0 { + rows, err = r.queryMessages(ctx, + `SELECT id, session_id, role, content, tokens_used, created_at + FROM messages + WHERE session_id = $1 AND id < $2 + ORDER BY id DESC + LIMIT $3`, + sessionID, beforeID, limit, + ) + } else { + rows, err = r.queryMessages(ctx, + `SELECT id, session_id, role, content, tokens_used, created_at + FROM messages + WHERE session_id = $1 + ORDER BY id DESC + LIMIT $2`, + sessionID, limit, + ) + } + if err != nil { + return nil, err + } + + // 反转为升序 + for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { + rows[i], rows[j] = rows[j], rows[i] + } + + return rows, nil +} + +func (r *PgMessageRepository) queryMessages(ctx context.Context, query string, args ...any) ([]StoredMessage, error) { + pgxRows, err := r.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer pgxRows.Close() + + var messages []StoredMessage + for pgxRows.Next() { + var m StoredMessage + if err := pgxRows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt); err != nil { + return nil, err + } + messages = append(messages, m) + } + if err := pgxRows.Err(); err != nil { + return nil, err + } + return messages, nil +} + +func (r *PgMessageRepository) GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error) { + var m StoredMessage + err := r.pool.QueryRow(ctx, + `SELECT id, session_id, role, content, tokens_used, created_at + FROM messages + WHERE session_id = $1 + ORDER BY id DESC + LIMIT 1`, + sessionID, + ).Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.TokensUsed, &m.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrMessageNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID string) (int, error) { + var count int + err := r.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM messages WHERE session_id = $1`, + sessionID, + ).Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} -- 2.49.1 From f4515ce5e499d7bfb8d730765ebfda803eb9e115 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:54:33 +0800 Subject: [PATCH 38/46] =?UTF-8?q?feat:=20Phase=208.3=20=E2=80=94=20Session?= =?UTF-8?q?=20Manager=20=E6=B3=A8=E5=85=A5=20MessageRepository=EF=BC=8CApp?= =?UTF-8?q?endMessage=20=E5=90=AF=E7=94=A8=20Write-Through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/session/memory.go | 33 ++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index 2df0be3..be8da3c 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -10,6 +10,7 @@ import ( "github.com/hhs/camtalk/internal/logger" "github.com/hhs/camtalk/internal/models" + "github.com/hhs/camtalk/internal/store" ) const ( @@ -33,11 +34,23 @@ type MemoryManager struct { ttl time.Duration maxHistory int stopCleaner chan struct{} + msgRepo store.MessageRepository // 可选,消息持久化(Write-Through) +} + +// Option MemoryManager 的函数式选项。 +type Option func(*MemoryManager) + +// WithMessageRepository 注入消息持久化仓库,启用 Write-Through 模式。 +func WithMessageRepository(repo store.MessageRepository) Option { + return func(m *MemoryManager) { + m.msgRepo = repo + } } // NewMemoryManager 创建内存版 SessionManager。 // ttl 为会话过期时间,maxHistory 为对话历史上限(0 表示使用默认值 20)。 -func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager { +// opts 为可选配置,如 WithMessageRepository 启用消息持久化。 +func NewMemoryManager(ttl time.Duration, maxHistory int, opts ...Option) *MemoryManager { if ttl <= 0 { ttl = defaultTTL } @@ -52,6 +65,10 @@ func NewMemoryManager(ttl time.Duration, maxHistory int) *MemoryManager { stopCleaner: make(chan struct{}), } + for _, opt := range opts { + opt(m) + } + // 启动后台清理 goroutine,每分钟清除过期会话。 go m.cleanLoop() @@ -242,12 +259,13 @@ func (m *MemoryManager) GetHistory(_ context.Context, sessionID string, limit in } // AppendMessage 追加一条对话消息,同时刷新 TTL。 +// 若配置了 MessageRepository,消息会异步写入 PostgreSQL(Write-Through)。 func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg models.Message) error { m.mu.Lock() - defer m.mu.Unlock() entry, ok := m.sessions[sessionID] if !ok || m.isExpired(entry) { + m.mu.Unlock() return ErrSessionNotFound } @@ -266,6 +284,17 @@ func (m *MemoryManager) AppendMessage(_ context.Context, sessionID string, msg m now := time.Now() entry.lastActive = now entry.session.UpdatedAt = now + m.mu.Unlock() + + // Write-Through:异步写冷存储,不阻塞调用方 + if m.msgRepo != nil { + go func() { + if err := m.msgRepo.SaveMessage(context.Background(), sessionID, msg, 0); err != nil { + logger.Log.Warnw("persist message failed", "session", sessionID, "error", err) + } + }() + } + return nil } -- 2.49.1 From 7b745018c54110eed28500ec7441d566a1f05610 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:56:33 +0800 Subject: [PATCH 39/46] =?UTF-8?q?feat:=20Phase=208.4=20=E2=80=94=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20LoadSession=20=E5=92=8C=20LoadSessionFromD?= =?UTF-8?q?B=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BB=8E=20PostgreSQL=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=BC=9A=E8=AF=9D=E5=88=B0=E5=86=85=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/session/memory.go | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index be8da3c..bc300a3 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -307,6 +307,48 @@ func generateTitle(firstMessage string) string { return firstMessage } +// LoadSession 从外部存储加载会话到内存热存储。 +// 用于 conversation_id 恢复场景:WS 连接时会话不在内存中,从 PostgreSQL 加载。 +// 若会话已在内存中,返回 nil(幂等)。 +func (m *MemoryManager) LoadSession(sess *models.Session, messages []models.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.sessions[sess.ID]; ok { + return nil // 已在内存中,无需重复加载 + } + + m.sessions[sess.ID] = &sessionEntry{ + session: *sess, + history: messages, + lastActive: time.Now(), + } + + logger.Log.Debugw("session loaded from DB", "session", sess.ID, "messages", len(messages)) + return nil +} + +// LoadSessionFromRepo 从 MessageRepository 加载会话消息并注册到内存。 +// 适用于已注入 MessageRepository 的场景,调用方只需传入 session 元数据。 +func (m *MemoryManager) LoadSessionFromRepo(ctx context.Context, sess *models.Session) error { + if m.msgRepo == nil { + return m.LoadSession(sess, nil) + } + + // 从冷存储加载全部消息(limit=0 表示全量) + stored, err := m.msgRepo.GetMessages(ctx, sess.ID, 0, 0) + if err != nil { + return err + } + + messages := make([]models.Message, len(stored)) + for i, s := range stored { + messages[i] = models.Message{Role: s.Role, Content: s.Content} + } + + return m.LoadSession(sess, messages) +} + // SetActiveRequest 标记当前正在处理的请求 ID。 func (m *MemoryManager) SetActiveRequest(_ context.Context, sessionID string, requestID string) error { m.mu.Lock() -- 2.49.1 From c3a32ce27620c65d02ef557c1c728d80b2e51e4a Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 17:58:42 +0800 Subject: [PATCH 40/46] =?UTF-8?q?feat:=20Phase=208.5=20=E2=80=94=20Convers?= =?UTF-8?q?ationSummary=20=E6=9F=A5=E8=AF=A2=E4=BC=98=E5=8C=96=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20SQL=20=E8=81=9A=E5=90=88=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/session/memory.go | 30 +++++++++++++++---- backend/internal/store/message.go | 10 +++++++ backend/internal/store/message_pg.go | 43 ++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index bc300a3..bfb232c 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -187,12 +187,13 @@ func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title s } // ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。 -func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { +// 若配置了 MessageRepository,消息统计从 PostgreSQL 聚合查询(更准确)。 +func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { m.mu.RLock() - defer m.mu.RUnlock() // 收集该用户的所有 session var list []ConversationSummary + var sessionIDs []string for _, entry := range m.sessions { if entry.session.UserID != userID { continue @@ -201,15 +202,32 @@ func (m *MemoryManager) ListByUser(_ context.Context, userID string, page, size continue } summary := ConversationSummary{ - ID: entry.session.ID, - Title: entry.session.Title, - MessageCount: len(entry.history), - UpdatedAt: entry.lastActive, + ID: entry.session.ID, + Title: entry.session.Title, + UpdatedAt: entry.lastActive, } + // 先用内存值填充,后续可能被 PG 统计覆盖 + summary.MessageCount = len(entry.history) if len(entry.history) > 0 { summary.LastMessage = entry.history[len(entry.history)-1].Content } list = append(list, summary) + sessionIDs = append(sessionIDs, entry.session.ID) + } + m.mu.RUnlock() + + // 若配置了 msgRepo,从 PostgreSQL 获取更准确的消息统计 + if m.msgRepo != nil && len(sessionIDs) > 0 { + if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil { + for i := range list { + if s, ok := stats[list[i].ID]; ok { + list[i].LastMessage = s.LastMessage + list[i].MessageCount = s.MessageCount + } + } + } else { + logger.Log.Warnw("get session message stats failed, falling back to in-memory", "error", err) + } } // 按 UpdatedAt 降序排序 diff --git a/backend/internal/store/message.go b/backend/internal/store/message.go index 629af20..8220b8d 100644 --- a/backend/internal/store/message.go +++ b/backend/internal/store/message.go @@ -27,6 +27,16 @@ type MessageRepository interface { // GetMessageCount 获取会话的消息总数。 GetMessageCount(ctx context.Context, sessionID string) (int, error) + + // GetSessionMessageStats 批量查询多个会话的消息统计(last_message + message_count)。 + // 返回的 map key 为 sessionID,仅包含有消息的会话。 + GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) +} + +// SessionMessageStats 单个会话的消息统计(SQL 聚合查询结果)。 +type SessionMessageStats struct { + LastMessage string + MessageCount int } // StoredMessage 持久化消息模型(store 层)。 diff --git a/backend/internal/store/message_pg.go b/backend/internal/store/message_pg.go index 8c1512a..0bc0997 100644 --- a/backend/internal/store/message_pg.go +++ b/backend/internal/store/message_pg.go @@ -118,3 +118,46 @@ func (r *PgMessageRepository) GetMessageCount(ctx context.Context, sessionID str } return count, nil } + +func (r *PgMessageRepository) GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]SessionMessageStats, error) { + if len(sessionIDs) == 0 { + return map[string]SessionMessageStats{}, nil + } + + rows, err := r.pool.Query(ctx, + `WITH stats AS ( + SELECT session_id, COUNT(*) AS cnt + FROM messages + WHERE session_id = ANY($1) + GROUP BY session_id + ), + last_msg AS ( + SELECT DISTINCT ON (session_id) session_id, content + FROM messages + WHERE session_id = ANY($1) + ORDER BY session_id, id DESC + ) + SELECT s.session_id, s.cnt, COALESCE(lm.content, '') + FROM stats s + LEFT JOIN last_msg lm ON lm.session_id = s.session_id`, + sessionIDs, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]SessionMessageStats) + for rows.Next() { + var sid string + var stats SessionMessageStats + if err := rows.Scan(&sid, &stats.MessageCount, &stats.LastMessage); err != nil { + return nil, err + } + result[sid] = stats + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} -- 2.49.1 From b57adf153b4fcfe885fce7ee6402ff2fca19ee47 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 18:05:50 +0800 Subject: [PATCH 41/46] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=A8=A1=E5=9D=97=20REST=20API=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PLAN_USER_MODULE.md 新增「前端 API 接口参考」章节 - 03-接口文档.md 同步认证接口、对话接口、WebSocket 认证变更 - 新增错误码 USERNAME_TAKEN / INVALID_CREDENTIALS / INVALID_TOKEN / INVALID_INPUT - 数据模型补充 User / ConversationSummary / StoredMessage 及对应 TypeScript 类型 - 配置结构体补充 AuthConfig(JWTSecret / AccessTTL / RefreshTTL) --- docs/03-接口文档.md | 623 ++++++++++++++++++++++++++++++++++++--- docs/PLAN_USER_MODULE.md | 582 ++++++++++++++++++++++++++++++++++++ 2 files changed, 1157 insertions(+), 48 deletions(-) diff --git a/docs/03-接口文档.md b/docs/03-接口文档.md index f26a79a..3f7d28f 100644 --- a/docs/03-接口文档.md +++ b/docs/03-接口文档.md @@ -13,16 +13,28 @@ ``` 浏览器 Go Gateway :8080 - WebSocket Client <--> /ws (实时对话) - HTTP Client --> GET /api/health - HTTP Client <--> POST/DELETE /api/sessions + WebSocket Client <--> /ws?token= (实时对话,需 JWT 认证) + HTTP Client --> GET /api/health (健康检查) + HTTP Client <--> POST /api/auth/* (注册/登录/刷新/登出) + HTTP Client <--> GET/POST/PATCH/DELETE (对话 CRUD) + /api/conversations/* + HTTP Client <--> GET /api/conversations/:id (历史消息) + /messages + HTTP Client ~~> POST/DELETE /api/sessions (已废弃,保留兼容) ``` --- ## 一、WebSocket 协议 -连接地址:`ws://localhost:8080/ws` +连接地址:`ws://localhost:8080/ws?token=&conversation_id=` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `token` | 是 | JWT access_token,缺失或无效时返回 401 | +| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 | + +> 详见"REST API → WebSocket 认证变更"章节。 ### 消息格式约定 @@ -95,8 +107,9 @@ interface PingMessage { ```typescript interface ConnectedMessage { type: "connected"; - session_id: string; // 服务端生成的会话 ID - server_version: string; // 服务端版本号,如 "0.1.0" + session_id: string; // 服务端生成的会话 ID + conversation_id: string; // 同 session_id,便于前端统一使用 + server_version: string; // 服务端版本号,如 "0.1.0" } ``` @@ -265,13 +278,446 @@ Client Server ## 二、REST API +### 通用约定 + +#### 认证方式 + +需要认证的接口在请求头携带 JWT access token: + +``` +Authorization: Bearer +``` + +未认证或 token 过期时返回 `401 Unauthorized`。 + +#### 错误响应格式 + +所有错误响应统一结构: + +```typescript +interface ApiError { + code: string; // 机器可读错误码 + message: string; // 人类可读描述 +} +``` + +示例: + +```json +{ + "code": "USERNAME_TAKEN", + "message": "username already taken" +} +``` + +#### 输入校验规则 + +| 字段 | 规则 | +|------|------| +| `username` | 3-64 字符,仅允许字母、数字、下划线 | +| `password` | 8-72 字符 | + +--- + +### 认证接口(`/api/auth`) + +#### 注册 + +``` +POST /api/auth/register +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface RegisterRequest { + username: string; // 3-64 字符 + password: string; // 8-72 字符 +} +``` + +**成功响应** `201 Created`: + +```typescript +interface AuthResponse { + user: { + id: string; // UUID + username: string; + created_at: string; // ISO 8601 + }; + access_token: string; // JWT,15 分钟有效 + refresh_token: string; // JWT,7 天有效 +} +``` + +```json +{ + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "alice", + "created_at": "2026-06-14T10:00:00Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 | +| 409 | `USERNAME_TAKEN` | 用户名已存在 | + +#### 登录 + +``` +POST /api/auth/login +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface LoginRequest { + username: string; + password: string; +} +``` + +**成功响应** `200 OK`:同 `AuthResponse` 结构。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 | +| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 | + +#### 刷新 Token + +``` +POST /api/auth/refresh +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface RefreshRequest { + refresh_token: string; // 之前签发的 refresh_token +} +``` + +**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token,旧 refresh_token 失效——Token 轮转)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 | + +#### 登出 + +``` +POST /api/auth/logout +Content-Type: application/json +Authorization: Bearer +``` + +**请求体**: + +```typescript +interface LogoutRequest { + refresh_token: string; // 要废弃的 refresh_token +} +``` + +**成功响应** `204 No Content`(无响应体)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | access_token 无效或已过期 | + +--- + +### 对话接口(`/api/conversations`) + +> 以下所有接口均需认证(`Authorization: Bearer `),省略不重复标注。 + +#### 对话列表 + +``` +GET /api/conversations?page=1&size=20 +``` + +**查询参数**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `page` | int | 1 | 页码,从 1 开始 | +| `size` | int | 20 | 每页条数,最大 50 | + +**成功响应** `200 OK`: + +```typescript +interface ConversationListResponse { + conversations: ConversationSummary[]; + total: number; // 总条数 + page: number; + size: number; +} + +interface ConversationSummary { + id: string; // 对话 ID(即 session_id) + title: string; // 对话标题(首条消息前 20 字) + last_message: string; // 最后一条消息内容预览 + message_count: number; // 消息总数 + updated_at: string; // ISO 8601,最后活跃时间 +} +``` + +```json +{ + "conversations": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "这是一朵红色的玫瑰…", + "last_message": "它看起来很美丽。", + "message_count": 4, + "updated_at": "2026-06-14T10:05:30Z" + } + ], + "total": 1, + "page": 1, + "size": 20 +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | + +#### 创建对话 + +``` +POST /api/conversations +Content-Type: application/json +``` + +**请求体**(可选,全部有默认值): + +```typescript +interface CreateConversationRequest { + config?: { + tts_enabled?: boolean; // 默认 true + detail_level?: "low" | "high"; // 默认 "low" + language?: string; // 默认 "zh-CN" + }; +} +``` + +**成功响应** `201 Created`: + +```typescript +interface ConversationDetail { + id: string; + title: string; + config: { + tts_enabled: boolean; + detail_level: "low" | "high"; + language: string; + }; + created_at: string; // ISO 8601 +} +``` + +```json +{ + "id": "660e8400-e29b-41d4-a716-446655440001", + "title": "新对话", + "config": { + "tts_enabled": true, + "detail_level": "low", + "language": "zh-CN" + }, + "created_at": "2026-06-14T11:00:00Z" +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | + +#### 获取对话详情 + +``` +GET /api/conversations/:id +``` + +**成功响应** `200 OK`:同 `ConversationDetail` 结构。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +#### 更新对话标题 + +``` +PATCH /api/conversations/:id +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface UpdateTitleRequest { + title: string; // 1-100 字符 +} +``` + +**成功响应** `200 OK`: + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "新的自定义标题" +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | title 为空或超长 | +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +#### 删除对话 + +``` +DELETE /api/conversations/:id +``` + +**成功响应** `204 No Content`(无响应体)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +#### 获取对话消息 + +``` +GET /api/conversations/:id/messages?limit=50&before= +``` + +**查询参数**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `limit` | int | 50 | 返回条数,最大 100 | +| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 | + +**成功响应** `200 OK`: + +```typescript +interface MessagesResponse { + messages: StoredMessage[]; + has_more: boolean; // 是否还有更早的消息 +} + +interface StoredMessage { + id: number; // 自增 ID,用于游标分页 + role: "user" | "assistant"; + content: string; + tokens_used: number; // 该条消息消耗的 token 数 + created_at: string; // ISO 8601 +} +``` + +```json +{ + "messages": [ + { + "id": 1001, + "role": "user", + "content": "这是什么花?", + "tokens_used": 0, + "created_at": "2026-06-14T10:01:00Z" + }, + { + "id": 1002, + "role": "assistant", + "content": "这是一朵红色的玫瑰。", + "tokens_used": 42, + "created_at": "2026-06-14T10:01:02Z" + } + ], + "has_more": false +} +``` + +**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +--- + +### WebSocket 认证变更 + +连接地址变更为带 token 的查询参数: + +``` +ws://localhost:8080/ws?token=&conversation_id= +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `token` | 是 | JWT access_token | +| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 | + +**认证失败响应**(HTTP 升级前返回): + +| 状态码 | 场景 | +|--------|------| +| 401 | token 缺失、无效或已过期 | + +**conversation_id 校验失败**: + +| 场景 | 处理 | +|------|------| +| 对话不存在 | 返回 401,`{"error": "SESSION_NOT_FOUND"}` | +| 对话不属于当前用户 | 返回 401,`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) | + +--- + ### 健康检查 ``` GET /api/health ``` -响应: +无需认证。 + +**成功响应** `200 OK`: ```json { @@ -282,43 +728,21 @@ GET /api/health } ``` -### 创建会话(可选,MVP 自动创建) +--- + +### ~~旧会话接口~~(已废弃) + +> 以下端点已废弃,保留仅为向后兼容。新代码应使用 `/api/conversations` 系列接口。 ``` -POST /api/sessions -Content-Type: application/json - -{ - "config": { - "tts_enabled": true, - "detail_level": "low", - "language": "zh-CN" - } -} +POST /api/sessions → 改用 POST /api/conversations +DELETE /api/sessions/{id} → 改用 DELETE /api/conversations/{id} ``` -响应: - -```json -{ - "session_id": "550e8400-e29b-41d4-a716-446655440000", - "created_at": "2026-06-12T15:41:00Z" -} -``` - -### 销毁会话 - -``` -DELETE /api/sessions/{session_id} -``` - -响应:`204 No Content` - ### 预留端点(暂不实现) | 端点 | 方法 | 用途 | |------|------|------| -| `/api/sessions/{id}/messages` | GET | 查询对话历史 | | `/api/usage` | GET | 查询用量统计 | | `/api/users/{id}/preferences` | GET/PUT | 用户偏好管理 | @@ -689,6 +1113,7 @@ type Config struct { Redis RedisConfig `mapstructure:"redis"` AI AIConfig `mapstructure:"ai"` Storage StorageConfig `mapstructure:"storage"` + Auth AuthConfig `mapstructure:"auth"` Log LogConfig `mapstructure:"log"` } @@ -746,6 +1171,12 @@ type StorageConfig struct { DSN string `mapstructure:"dsn"` // PostgreSQL 连接串,driver=postgres 时必填 } +type AuthConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置 + AccessTTL int `mapstructure:"access_ttl"` // 分钟,默认 15 + RefreshTTL int `mapstructure:"refresh_ttl"` // 分钟,默认 10080(7 天) +} + type LogConfig struct { Level string `mapstructure:"level"` // "debug" | "info" | "warn" | "error",默认 "info" Format string `mapstructure:"format"` // "json" | "console",生产用 json @@ -791,6 +1222,10 @@ ai: storage: driver: memory +auth: + access_ttl: 15 # access token 有效期(分钟) + refresh_ttl: 10080 # refresh token 有效期(分钟,7 天) + log: level: info format: console @@ -811,6 +1246,9 @@ Viper 自动将配置项映射为环境变量,规则:**前缀 `CAMTALK_` + | `ai.llm.model` | `CAMTALK_AI_LLM_MODEL` | `gpt-4o` | | `storage.driver` | `CAMTALK_STORAGE_DRIVER` | `postgres` | | `storage.dsn` | `CAMTALK_STORAGE_DSN` | — | +| `auth.jwt_secret` | `CAMTALK_AUTH_JWT_SECRET` | —(必填,仅环境变量) | +| `auth.access_ttl` | `CAMTALK_AUTH_ACCESS_TTL` | `15` | +| `auth.refresh_ttl` | `CAMTALK_AUTH_REFRESH_TTL` | `10080` | | `app.env` | `CAMTALK_APP_ENV` | `prod` | | `log.level` | `CAMTALK_LOG_LEVEL` | `warn` | | `log.format` | `CAMTALK_LOG_FORMAT` | `json` | @@ -892,6 +1330,7 @@ CAMTALK_AI_STT_API_KEY=xxx \ CAMTALK_AI_TTS_API_KEY=xxx \ CAMTALK_STORAGE_DRIVER=postgres \ CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \ +CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \ CAMTALK_LOG_LEVEL=warn \ CAMTALK_LOG_FORMAT=json \ ./bin/camtalk @@ -910,7 +1349,10 @@ CAMTALK_LOG_FORMAT=json \ type Session struct { ID string `json:"session_id"` + UserID string `json:"user_id"` // 关联用户,空串表示匿名 + Title string `json:"title"` // 对话标题 CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` Config SessionConfig `json:"config"` } @@ -932,6 +1374,33 @@ type Message struct { Role string `json:"role"` // "user" | "assistant" Content string `json:"content"` } + +// ---- 用户模块 ---- + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ConversationSummary struct { + ID string `json:"id"` + Title string `json:"title"` + LastMessage string `json:"last_message"` + MessageCount int `json:"message_count"` + UpdatedAt time.Time `json:"updated_at"` +} + +type StoredMessage struct { + ID int64 `json:"id"` + SessionID string `json:"-"` + Role string `json:"role"` + Content string `json:"content"` + TokensUsed int `json:"tokens_used"` + CreatedAt time.Time `json:"created_at"` +} ``` ### TypeScript 前端模型 @@ -957,6 +1426,60 @@ interface ChatMessage { tokensUsed?: number; } +// ---- 用户模块 ---- + +interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +interface User { + id: string; // UUID + username: string; + created_at: string; // ISO 8601 +} + +interface AuthResponse { + user: User; + access_token: string; + refresh_token: string; +} + +interface ConversationSummary { + id: string; + title: string; + last_message: string; + message_count: number; + updated_at: string; +} + +interface ConversationListResponse { + conversations: ConversationSummary[]; + total: number; + page: number; + size: number; +} + +interface ConversationDetail { + id: string; + title: string; + config: SessionConfig; + created_at: string; +} + +interface StoredMessage { + id: number; + role: "user" | "assistant"; + content: string; + tokens_used: number; + created_at: string; +} + +interface MessagesResponse { + messages: StoredMessage[]; + has_more: boolean; +} + // WebSocket 消息联合类型 type ServerMessage = | ConnectedMessage @@ -1054,18 +1577,22 @@ func NewApp(cfg *Config) *App { ## 九、错误码 -| 错误码 | 含义 | 客户端处理建议 | -|--------|------|--------------| -| `INVALID_MESSAGE` | 消息格式不合法 | 检查 JSON 结构,不重试 | -| `SESSION_NOT_FOUND` | 会话不存在或已过期 | 重新建立 WebSocket 连接 | -| `RATE_LIMITED` | 请求频率超限 | 延迟后重试,提示用户稍等 | -| `IMAGE_TOO_LARGE` | 图像超过 4MB 限制 | 降低分辨率或压缩质量 | -| `AUDIO_TOO_SHORT` | 音频片段 < 250ms | 忽略,等待下次语音输入 | -| `LLM_TIMEOUT` | LLM 推理超时(>10s) | 提示用户重试 | -| `LLM_ERROR` | LLM 服务异常 | 提示用户重试,服务端记录日志 | -| `STT_ERROR` | 语音识别失败 | 回退到纯文本输入模式 | -| `TTS_ERROR` | 语音合成失败 | 静默回退到纯文本回复 | -| `INTERNAL_ERROR` | 服务端内部错误 | 提示用户重试 | +| 错误码 | HTTP 状态码 | 含义 | 客户端处理建议 | +|--------|-----------|------|--------------| +| `INVALID_MESSAGE` | — | 消息格式不合法(WS) | 检查 JSON 结构,不重试 | +| `SESSION_NOT_FOUND` | 404 | 会话/对话不存在或已过期 | 重新建立连接或刷新列表 | +| `RATE_LIMITED` | 429 | 请求频率超限 | 延迟后重试,提示用户稍等 | +| `IMAGE_TOO_LARGE` | — | 图像超过 4MB 限制(WS) | 降低分辨率或压缩质量 | +| `AUDIO_TOO_SHORT` | — | 音频片段 < 250ms(WS) | 忽略,等待下次语音输入 | +| `LLM_TIMEOUT` | — | LLM 推理超时 >10s(WS) | 提示用户重试 | +| `LLM_ERROR` | — | LLM 服务异常(WS) | 提示用户重试,服务端记录日志 | +| `STT_ERROR` | — | 语音识别失败(WS) | 回退到纯文本输入模式 | +| `TTS_ERROR` | — | 语音合成失败(WS) | 静默回退到纯文本回复 | +| `INTERNAL_ERROR` | 500 | 服务端内部错误 | 提示用户重试 | +| `USERNAME_TAKEN` | 409 | 用户名已被注册 | 提示换一个用户名 | +| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 | +| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 | +| `INVALID_INPUT` | 400 | 请求参数校验失败 | 检查字段规则后重试 | ## 十、连接管理 diff --git a/docs/PLAN_USER_MODULE.md b/docs/PLAN_USER_MODULE.md index 2bc764e..6e1dd9f 100644 --- a/docs/PLAN_USER_MODULE.md +++ b/docs/PLAN_USER_MODULE.md @@ -713,6 +713,588 @@ func main() { --- +## 前端 API 接口参考 + +本章节为前端开发者提供完整的 REST API 契约。所有接口以 JSON 通信,基地址与 WebSocket 同源(开发环境 `http://localhost:8080`,生产环境通过 Nginx 反代)。 + +### 通用约定 + +#### 认证方式 + +需要认证的接口在请求头携带 JWT access token: + +``` +Authorization: Bearer +``` + +未认证或 token 过期时返回 `401 Unauthorized`。 + +#### 错误响应格式 + +所有错误响应统一结构: + +```typescript +interface ApiError { + code: string; // 机器可读错误码 + message: string; // 人类可读描述 +} +``` + +示例: + +```json +{ + "code": "USERNAME_TAKEN", + "message": "username already taken" +} +``` + +#### 新增错误码 + +| 错误码 | HTTP 状态码 | 含义 | +|--------|-----------|------| +| `USERNAME_TAKEN` | 409 | 用户名已被注册 | +| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | +| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | +| `INVALID_INPUT` | 400 | 请求参数校验失败 | +| `SESSION_NOT_FOUND` | 404 | 对话不存在或无权访问 | + +#### 输入校验规则 + +| 字段 | 规则 | +|------|------| +| `username` | 3-64 字符,仅允许字母、数字、下划线 | +| `password` | 8-72 字符 | + +--- + +### 一、认证接口(`/api/auth`) + +#### 1.1 注册 + +``` +POST /api/auth/register +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface RegisterRequest { + username: string; // 3-64 字符 + password: string; // 8-72 字符 +} +``` + +**成功响应** `201 Created`: + +```typescript +interface AuthResponse { + user: { + id: string; // UUID + username: string; + created_at: string; // ISO 8601 + }; + access_token: string; // JWT,15 分钟有效 + refresh_token: string; // JWT,7 天有效 +} +``` + +```json +{ + "user": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "username": "alice", + "created_at": "2026-06-14T10:00:00Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | 用户名/密码不符合校验规则 | +| 409 | `USERNAME_TAKEN` | 用户名已存在 | + +--- + +#### 1.2 登录 + +``` +POST /api/auth/login +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface LoginRequest { + username: string; + password: string; +} +``` + +**成功响应** `200 OK`:同 `AuthResponse` 结构。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | 请求参数缺失或格式错误 | +| 401 | `INVALID_CREDENTIALS` | 用户名或密码错误 | + +--- + +#### 1.3 刷新 Token + +``` +POST /api/auth/refresh +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface RefreshRequest { + refresh_token: string; // 之前签发的 refresh_token +} +``` + +**成功响应** `200 OK`:同 `AuthResponse` 结构(返回新的 access_token + refresh_token,旧 refresh_token 失效——Token 轮转)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | refresh_token 无效或已过期 | + +--- + +#### 1.4 登出 + +``` +POST /api/auth/logout +Content-Type: application/json +Authorization: Bearer +``` + +**请求体**: + +```typescript +interface LogoutRequest { + refresh_token: string; // 要废弃的 refresh_token +} +``` + +**成功响应** `204 No Content`(无响应体)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | access_token 无效或已过期 | + +--- + +### 二、对话接口(`/api/conversations`) + +> 以下所有接口均需认证(`Authorization: Bearer `),省略不重复标注。 + +#### 2.1 对话列表 + +``` +GET /api/conversations?page=1&size=20 +``` + +**查询参数**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `page` | int | 1 | 页码,从 1 开始 | +| `size` | int | 20 | 每页条数,最大 50 | + +**成功响应** `200 OK`: + +```typescript +interface ConversationListResponse { + conversations: ConversationSummary[]; + total: number; // 总条数 + page: number; + size: number; +} + +interface ConversationSummary { + id: string; // 对话 ID(即 session_id) + title: string; // 对话标题(首条消息前 20 字) + last_message: string; // 最后一条消息内容预览 + message_count: number; // 消息总数 + updated_at: string; // ISO 8601,最后活跃时间 +} +``` + +```json +{ + "conversations": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "这是一朵红色的玫瑰…", + "last_message": "它看起来很美丽。", + "message_count": 4, + "updated_at": "2026-06-14T10:05:30Z" + } + ], + "total": 1, + "page": 1, + "size": 20 +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | + +--- + +#### 2.2 创建对话 + +``` +POST /api/conversations +Content-Type: application/json +``` + +**请求体**(可选,全部有默认值): + +```typescript +interface CreateConversationRequest { + config?: { + tts_enabled?: boolean; // 默认 true + detail_level?: "low" | "high"; // 默认 "low" + language?: string; // 默认 "zh-CN" + }; +} +``` + +**成功响应** `201 Created`: + +```typescript +interface ConversationDetail { + id: string; + title: string; + config: { + tts_enabled: boolean; + detail_level: "low" | "high"; + language: string; + }; + created_at: string; // ISO 8601 +} +``` + +```json +{ + "id": "660e8400-e29b-41d4-a716-446655440001", + "title": "新对话", + "config": { + "tts_enabled": true, + "detail_level": "low", + "language": "zh-CN" + }, + "created_at": "2026-06-14T11:00:00Z" +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | + +--- + +#### 2.3 获取对话详情 + +``` +GET /api/conversations/:id +``` + +**成功响应** `200 OK`:同 `ConversationDetail` 结构。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +--- + +#### 2.4 更新对话标题 + +``` +PATCH /api/conversations/:id +Content-Type: application/json +``` + +**请求体**: + +```typescript +interface UpdateTitleRequest { + title: string; // 1-100 字符 +} +``` + +**成功响应** `200 OK`: + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "title": "新的自定义标题" +} +``` + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 400 | `INVALID_INPUT` | title 为空或超长 | +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +--- + +#### 2.5 删除对话 + +``` +DELETE /api/conversations/:id +``` + +**成功响应** `204 No Content`(无响应体)。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +--- + +#### 2.6 获取对话消息 + +``` +GET /api/conversations/:id/messages?limit=50&before= +``` + +**查询参数**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `limit` | int | 50 | 返回条数,最大 100 | +| `before` | int64 | — | 游标分页:返回此 message_id 之前的消息(不含),用于加载更多 | + +**成功响应** `200 OK`: + +```typescript +interface MessagesResponse { + messages: StoredMessage[]; + has_more: boolean; // 是否还有更早的消息 +} + +interface StoredMessage { + id: number; // 自增 ID,用于游标分页 + role: "user" | "assistant"; + content: string; + tokens_used: number; // 该条消息消耗的 token 数 + created_at: string; // ISO 8601 +} +``` + +```json +{ + "messages": [ + { + "id": 1001, + "role": "user", + "content": "这是什么花?", + "tokens_used": 0, + "created_at": "2026-06-14T10:01:00Z" + }, + { + "id": 1002, + "role": "assistant", + "content": "这是一朵红色的玫瑰。", + "tokens_used": 42, + "created_at": "2026-06-14T10:01:02Z" + } + ], + "has_more": false +} +``` + +**分页用法**:首次请求不带 `before`,获取最新消息。滚动到顶部时,取当前列表最小的 `id` 作为 `before` 参数请求更早的消息。 + +**错误响应**: + +| 状态码 | code | 场景 | +|--------|------|------| +| 401 | `INVALID_TOKEN` | 未认证或 token 过期 | +| 404 | `SESSION_NOT_FOUND` | 对话不存在或不属于当前用户 | + +--- + +### 三、WebSocket 认证变更 + +连接地址变更为带 token 的查询参数: + +``` +ws://localhost:8080/ws?token=&conversation_id= +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `token` | 是 | JWT access_token | +| `conversation_id` | 否 | 恢复已有对话;省略则创建新对话 | + +**认证失败响应**(HTTP 升级前返回): + +| 状态码 | 场景 | +|--------|------| +| 401 | token 缺失、无效或已过期 | + +**conversation_id 校验失败**: + +| 场景 | 处理 | +|------|------| +| 对话不存在 | 返回 401,`{"error": "SESSION_NOT_FOUND"}` | +| 对话不属于当前用户 | 返回 401,`{"error": "SESSION_NOT_FOUND"}`(与不存在相同,避免信息泄露) | + +**连接成功后**:`connected` 消息不变,新增 `conversation_id` 字段标识当前对话: + +```typescript +interface ConnectedMessage { + type: "connected"; + session_id: string; // 对话 ID + conversation_id: string; // 同 session_id,便于前端统一使用 + server_version: string; +} +``` + +--- + +### 四、前端调用示例 + +#### 认证状态管理 + +```typescript +// 存储 token(建议 localStorage 或内存,视安全需求) +interface AuthTokens { + accessToken: string; + refreshToken: string; +} + +// 请求拦截器:自动附加 Authorization 头 +async function authFetch(url: string, options: RequestInit = {}): Promise { + const tokens = getStoredTokens(); + const headers = { + ...options.headers, + "Authorization": `Bearer ${tokens.accessToken}`, + }; + + let resp = await fetch(url, { ...options, headers }); + + // 401 时尝试刷新 token + if (resp.status === 401 && tokens.refreshToken) { + const refreshResp = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: tokens.refreshToken }), + }); + + if (refreshResp.ok) { + const newTokens: AuthResponse = await refreshResp.json(); + storeTokens({ + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token, + }); + // 用新 token 重试原请求 + headers["Authorization"] = `Bearer ${newTokens.access_token}`; + resp = await fetch(url, { ...options, headers }); + } else { + // refresh 也失败,跳转登录 + redirectToLogin(); + } + } + + return resp; +} +``` + +#### 注册 + 登录 + +```typescript +async function register(username: string, password: string): Promise { + const resp = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + if (!resp.ok) { + const err: ApiError = await resp.json(); + throw new Error(err.message); // "username already taken" 等 + } + + return resp.json(); +} +``` + +#### 获取对话列表 + +```typescript +async function getConversations(page = 1, size = 20): Promise { + const resp = await authFetch( + `/api/conversations?page=${page}&size=${size}` + ); + if (!resp.ok) throw new Error("Failed to load conversations"); + return resp.json(); +} +``` + +#### 加载对话历史消息 + +```typescript +async function getMessages( + conversationId: string, + limit = 50, + before?: number +): Promise { + let url = `/api/conversations/${conversationId}/messages?limit=${limit}`; + if (before !== undefined) url += `&before=${before}`; + + const resp = await authFetch(url); + if (!resp.ok) throw new Error("Failed to load messages"); + return resp.json(); +} +``` + +#### 建立 WebSocket 连接(带认证) + +```typescript +function connectWebSocket(accessToken: string, conversationId?: string): WebSocket { + let url = `/ws?token=${encodeURIComponent(accessToken)}`; + if (conversationId) { + url += `&conversation_id=${encodeURIComponent(conversationId)}`; + } + return new WebSocket(url); +} +``` + +--- + ## 关键文件清单 ``` -- 2.49.1 From 8de956719c132df259a886853082d6b3773ecc3f Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 18:38:45 +0800 Subject: [PATCH 42/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F=EF=BC=8CWebSocket?= =?UTF-8?q?=20=E8=BF=9E=E6=8E=A5=E6=90=BA=E5=B8=A6=20JWT=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 AuthContext/AuthProvider:登录/注册状态管理,JWT 自动刷新 - 新增 AuthPage 组件:登录/注册表单,支持模式切换和前端校验 - 新增 api.ts:封装 auth REST API 客户端(register/login/refresh/logout) - WebSocket 连接时拼接 ?token=,重连自动携带 - useVisionSession 接受 accessToken 参数并透传 - App.tsx 包裹 AuthProvider,未登录时显示登录页 - storage.ts 新增 token/user 的 localStorage 存储 - i18n 新增中/英/日三语 auth 翻译 - App.css 新增 auth 页面和用户 badge 样式 --- frontend/src/App.css | 207 +++++++++++++++++ frontend/src/App.tsx | 30 ++- frontend/src/components/AuthPage/index.tsx | 130 +++++++++++ .../src/components/WebSocketManager/index.tsx | 2 +- frontend/src/hooks/useVisionSession.ts | 10 +- frontend/src/lib/api.ts | 106 +++++++++ frontend/src/lib/auth.tsx | 209 ++++++++++++++++++ frontend/src/lib/i18n/en-US.ts | 15 ++ frontend/src/lib/i18n/ja-JP.ts | 15 ++ frontend/src/lib/i18n/zh-CN.ts | 15 ++ frontend/src/lib/storage.ts | 70 ++++++ frontend/src/lib/websocket.ts | 11 +- 12 files changed, 808 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/AuthPage/index.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/auth.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 544d2d0..b31ff55 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1534,3 +1534,210 @@ body { .header__menu-btn:active { transform: scale(0.92); } + +/* ---- Auth Page ---- */ + +.auth-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-bg); + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 32px; + box-shadow: 0 16px 64px rgba(10, 10, 15, 0.4); +} + +.auth-card__header { + text-align: center; + margin-bottom: 28px; +} + +.auth-card__logo { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; + margin-bottom: 6px; +} + +.auth-card__subtitle { + font-size: 0.82rem; + color: var(--color-text-muted); +} + +/* Auth form tabs */ +.auth-form__tabs { + display: flex; + gap: 2px; + background: var(--color-surface-2); + border-radius: var(--radius-sm); + padding: 3px; + margin-bottom: 24px; +} + +.auth-tab { + flex: 1; + padding: 8px 0; + border: none; + border-radius: 6px; + background: none; + color: var(--color-text-muted); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); +} + +.auth-tab:hover { + color: var(--color-text); +} + +.auth-tab--active { + background: var(--color-primary); + color: white; +} + +/* Auth form fields */ +.auth-form__fields { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.auth-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-field__label { + font-size: 0.78rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.auth-field__input { + padding: 10px 14px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-2); + color: var(--color-text); + font-size: 0.85rem; + outline: none; + transition: border-color var(--transition-fast); +} + +.auth-field__input::placeholder { + color: var(--color-text-muted); + opacity: 0.6; +} + +.auth-field__input:focus { + border-color: var(--color-primary); +} + +/* Error message */ +.auth-form__error { + padding: 8px 12px; + margin-bottom: 12px; + border-radius: var(--radius-sm); + background: rgba(248, 113, 113, 0.1); + color: var(--color-error); + font-size: 0.78rem; + font-weight: 500; + border: 1px solid rgba(248, 113, 113, 0.2); +} + +/* Submit button */ +.auth-form__submit { + width: 100%; + padding: 11px 0; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: white; + font-size: 0.88rem; + font-weight: 600; + cursor: pointer; + transition: all var(--transition-fast); + letter-spacing: 0.01em; +} + +.auth-form__submit:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.auth-form__submit:active:not(:disabled) { + transform: translateY(1px); +} + +.auth-form__submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Footer link */ +.auth-card__footer { + text-align: center; + margin-top: 20px; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.auth-card__link { + background: none; + border: none; + color: var(--color-primary); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + margin-left: 4px; + text-decoration: underline; + text-underline-offset: 2px; +} + +.auth-card__link:hover { + color: var(--color-primary-hover); +} + +/* ---- User badge in header ---- */ + +.header__user { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.header__username { + font-weight: 500; + color: var(--color-text); +} + +.header__logout-btn { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 500; + padding: 4px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.header__logout-btn:hover { + border-color: var(--color-error); + color: var(--color-error); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7eecfe6..fd123bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { SessionSidebar } from "./components/SessionSidebar"; import { ToastContainer } from "./components/Toast"; +import { AuthPage } from "./components/AuthPage"; +import { AuthProvider, useAuth } from "./lib/auth"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; import type { Locale } from "./lib/i18n"; @@ -22,6 +24,7 @@ type VisionMode = "realtime" | "ondemand" | "chat"; /** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ function AppContent() { + const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth(); const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); @@ -82,7 +85,7 @@ function AppContent() { toggleCamera, toggleMic, sendTextMessage, - } = useVisionSession(); + } = useVisionSession(accessToken); const isConnected = connectionStatus === "connected"; @@ -194,6 +197,21 @@ function AppContent() { return () => window.removeEventListener("keydown", handler); }, [sidebarOpen]); + // Auth guard:未登录时显示登录页(放在所有 hooks 之后以遵守 Rules of Hooks) + if (isLoading) { + return ( +
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + return (
{/* ---- 顶部导航栏 ---- */} @@ -216,6 +234,12 @@ function AppContent() { {tr("app.title")}
+
+ {user?.username} + +
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")} @@ -489,7 +513,9 @@ function App() { return ( - + + + ); } diff --git a/frontend/src/components/AuthPage/index.tsx b/frontend/src/components/AuthPage/index.tsx new file mode 100644 index 0000000..99e0965 --- /dev/null +++ b/frontend/src/components/AuthPage/index.tsx @@ -0,0 +1,130 @@ +// ============================================================ +// AuthPage — 登录 / 注册页面 +// 职责:用户认证表单,支持登录和注册模式切换 +// ============================================================ + +import { useCallback, useState } from "react"; +import { useAuth } from "../../lib/auth"; +import { useI18n } from "../../lib/i18n"; + +type AuthMode = "login" | "register"; + +export function AuthPage() { + const { login, register } = useAuth(); + const { t } = useI18n(); + const [mode, setMode] = useState("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // 前端校验 + if (username.length < 3 || username.length > 64) { + setError(t("auth.error.usernameLength")); + return; + } + if (password.length < 8 || password.length > 72) { + setError(t("auth.error.passwordLength")); + return; + } + + setIsSubmitting(true); + const fn = mode === "login" ? login : register; + const result = await fn(username, password); + setIsSubmitting(false); + + if (result.error) { + setError(result.error); + } + }, + [username, password, mode, login, register, t] + ); + + const toggleMode = useCallback(() => { + setMode((m) => (m === "login" ? "register" : "login")); + setError(""); + }, []); + + return ( +
+
+
+

CamTalk

+

{t("auth.subtitle")}

+
+ +
+
+ + +
+ +
+ + + +
+ + {error &&
{error}
} + + +
+ +

+ {mode === "login" ? t("auth.noAccount") : t("auth.hasAccount")} + +

+
+
+ ); +} diff --git a/frontend/src/components/WebSocketManager/index.tsx b/frontend/src/components/WebSocketManager/index.tsx index a2c4537..48c00d7 100644 --- a/frontend/src/components/WebSocketManager/index.tsx +++ b/frontend/src/components/WebSocketManager/index.tsx @@ -25,7 +25,7 @@ export function useWebSocketManager() { return { status, lastMessage, - connect: () => wsClient.connect(), + connect: (token?: string) => wsClient.connect(token), disconnect: () => wsClient.disconnect(), send: wsClient.send.bind(wsClient), }; diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 497b996..0b68bf6 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -29,7 +29,7 @@ export interface SessionStats { totalTokens: number; } -export function useVisionSession() { +export function useVisionSession(accessToken?: string | null) { const { t } = useI18n(); const [messages, setMessages] = useState([]); const [currentReply, setCurrentReply] = useState(""); @@ -316,7 +316,7 @@ export function useVisionSession() { const startSession = useCallback(async () => { // 1. 确保 WebSocket 已连接 if (statusRef.current !== "connected") { - connect(); + connect(accessToken || undefined); // 等待连接完成(通过 status 变化触发后续流程,这里直接继续) } @@ -337,7 +337,7 @@ export function useVisionSession() { } else { console.warn("[Session] 无法获取麦克风权限,将以文本输入模式运行"); } - }, [startCamera, startMic, connect, startVAD]); + }, [startCamera, startMic, connect, startVAD, accessToken]); /** 结束会话 */ const stopSession = useCallback(async () => { @@ -425,7 +425,7 @@ export function useVisionSession() { { role: "user", content: text.trim(), timestamp: Date.now() }], ); // 自动连接 WebSocket - connect(); + connect(accessToken || undefined); return; } @@ -453,7 +453,7 @@ export function useVisionSession() { setIsProcessing(true); }, - [captureFrame, send, connect], + [captureFrame, send, connect, accessToken], ); return { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ea11424 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,106 @@ +// ============================================================ +// HTTP API 客户端 +// 职责:封装 REST API 请求(auth、conversations 等) +// ============================================================ + +const API_BASE = "/api"; + +interface ApiResponse { + data?: T; + error?: { code: string; message: string }; + status: number; +} + +async function request( + path: string, + options: RequestInit = {} +): Promise> { + const url = `${API_BASE}${path}`; + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + try { + const res = await fetch(url, { ...options, headers }); + const status = res.status; + + if (res.status === 204) { + return { status }; + } + + const body = await res.json(); + + if (!res.ok) { + return { + error: { code: body.error || "UNKNOWN", message: body.message || "请求失败" }, + status, + }; + } + + return { data: body as T, status }; + } catch (err) { + return { + error: { code: "NETWORK_ERROR", message: "网络连接失败,请检查网络" }, + status: 0, + }; + } +} + +function authHeaders(accessToken: string): Record { + return { Authorization: `Bearer ${accessToken}` }; +} + +// ---- Auth API ---- + +export interface AuthUser { + id: string; + username: string; + created_at: string; +} + +export interface AuthResponse { + user: AuthUser; + access_token: string; + refresh_token: string; +} + +export async function register( + username: string, + password: string +): Promise> { + return request("/auth/register", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function login( + username: string, + password: string +): Promise> { + return request("/auth/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); +} + +export async function refreshToken( + refresh_token: string +): Promise> { + return request("/auth/refresh", { + method: "POST", + body: JSON.stringify({ refresh_token }), + }); +} + +export async function logout( + accessToken: string, + refreshToken: string +): Promise> { + return request<{ message: string }>("/auth/logout", { + method: "POST", + headers: authHeaders(accessToken), + body: JSON.stringify({ refresh_token: refreshToken }), + }); +} diff --git a/frontend/src/lib/auth.tsx b/frontend/src/lib/auth.tsx new file mode 100644 index 0000000..b270f6d --- /dev/null +++ b/frontend/src/lib/auth.tsx @@ -0,0 +1,209 @@ +// ============================================================ +// Auth Context — 认证状态管理 +// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态 +// ============================================================ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import * as api from "./api"; +import type { AuthUser } from "./api"; +import { + clearAuth, + loadAccessToken, + loadRefreshToken, + loadUser, + saveAccessToken, + saveRefreshToken, + saveUser, +} from "./storage"; + +interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +interface AuthContextValue extends AuthState { + login: (username: string, password: string) => Promise<{ error?: string }>; + register: (username: string, password: string) => Promise<{ error?: string }>; + logout: () => Promise; + accessToken: string | null; +} + +const AuthContext = createContext(null); + +/** Access token 提前刷新的缓冲时间(秒) */ +const REFRESH_BUFFER_SEC = 60; + +/** 解析 JWT payload(不做签名验证) */ +function parseJwtPayload(token: string): { exp?: number } | null { + try { + const base64 = token.split(".")[1]; + const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(json); + } catch { + return null; + } +} + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(loadUser); + const [accessToken, setAccessToken] = useState(loadAccessToken); + const [isLoading, setIsLoading] = useState(true); + const refreshTimerRef = useRef | null>(null); + + // 清除定时器 + const clearRefreshTimer = useCallback(() => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }, []); + + // 持久化 token + user + const persistAuth = useCallback( + (authUser: AuthUser, access: string, refresh: string) => { + setUser(authUser); + setAccessToken(access); + saveAccessToken(access); + saveRefreshToken(refresh); + saveUser(authUser); + }, + [] + ); + + // 安排自动刷新 + const scheduleRefresh = useCallback( + (access: string) => { + clearRefreshTimer(); + const payload = parseJwtPayload(access); + if (!payload?.exp) return; + + const nowSec = Math.floor(Date.now() / 1000); + const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000); + + refreshTimerRef.current = setTimeout(async () => { + const rt = loadRefreshToken(); + if (!rt) return; + const res = await api.refreshToken(rt); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + } else { + // 刷新失败,清除 auth + clearAuth(); + setUser(null); + setAccessToken(null); + } + }, delayMs); + }, + [clearRefreshTimer, persistAuth] + ); + + // 初始化:检查已有 token 并尝试刷新 + useEffect(() => { + const init = async () => { + const storedAccess = loadAccessToken(); + const storedRefresh = loadRefreshToken(); + const storedUser = loadUser(); + + if (!storedAccess || !storedRefresh || !storedUser) { + setIsLoading(false); + return; + } + + // 检查 access token 是否过期 + const payload = parseJwtPayload(storedAccess); + const nowSec = Math.floor(Date.now() / 1000); + + if (payload?.exp && payload.exp > nowSec) { + // access token 仍然有效 + setUser(storedUser); + setAccessToken(storedAccess); + scheduleRefresh(storedAccess); + } else { + // access token 过期,尝试 refresh + const res = await api.refreshToken(storedRefresh); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + } else { + clearAuth(); + } + } + setIsLoading(false); + }; + + init(); + return () => clearRefreshTimer(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const login = useCallback( + async (username: string, password: string): Promise<{ error?: string }> => { + const res = await api.login(username, password); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + return {}; + } + return { error: res.error?.message || "登录失败" }; + }, + [persistAuth, scheduleRefresh] + ); + + const register = useCallback( + async (username: string, password: string): Promise<{ error?: string }> => { + const res = await api.register(username, password); + if (res.data) { + persistAuth(res.data.user, res.data.access_token, res.data.refresh_token); + scheduleRefresh(res.data.access_token); + return {}; + } + return { error: res.error?.message || "注册失败" }; + }, + [persistAuth, scheduleRefresh] + ); + + const logout = useCallback(async () => { + const at = accessToken; + const rt = loadRefreshToken(); + if (at && rt) { + await api.logout(at, rt); + } + clearRefreshTimer(); + clearAuth(); + setUser(null); + setAccessToken(null); + }, [accessToken, clearRefreshTimer]); + + const value = useMemo( + () => ({ + user, + isAuthenticated: !!user && !!accessToken, + isLoading, + login, + register, + logout, + accessToken, + }), + [user, accessToken, isLoading, login, register, logout] + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index b24bcb4..9f47068 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -130,4 +130,19 @@ export const enUS: TranslationMap = { "error.vadInit": "VAD initialization failed", "error.cameraAccess": "Cannot access camera", "error.micAccess": "Cannot access microphone", + + // Auth + "auth.subtitle": "AI Vision Assistant", + "auth.login": "Sign In", + "auth.register": "Sign Up", + "auth.username": "Username", + "auth.username.placeholder": "3-64 characters", + "auth.password": "Password", + "auth.password.placeholder": "8-72 characters", + "auth.submitting": "Please wait...", + "auth.noAccount": "Don't have an account?", + "auth.hasAccount": "Already have an account?", + "auth.error.usernameLength": "Username must be 3-64 characters", + "auth.error.passwordLength": "Password must be 8-72 characters", + "auth.logout": "Sign Out", }; diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 37bc1e8..34abfbe 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -130,4 +130,19 @@ export const jaJP: TranslationMap = { "error.vadInit": "VAD初期化に失敗しました", "error.cameraAccess": "カメラにアクセスできません", "error.micAccess": "マイクにアクセスできません", + + // Auth + "auth.subtitle": "AI ビジョンアシスタント", + "auth.login": "ログイン", + "auth.register": "新規登録", + "auth.username": "ユーザー名", + "auth.username.placeholder": "3〜64文字", + "auth.password": "パスワード", + "auth.password.placeholder": "8〜72文字", + "auth.submitting": "お待ちください...", + "auth.noAccount": "アカウントをお持ちでないですか?", + "auth.hasAccount": "すでにアカウントをお持ちですか?", + "auth.error.usernameLength": "ユーザー名は3〜64文字で入力してください", + "auth.error.passwordLength": "パスワードは8〜72文字で入力してください", + "auth.logout": "ログアウト", }; diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index 1f49ae0..337fc95 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -130,4 +130,19 @@ export const zhCN: TranslationMap = { "error.vadInit": "VAD 初始化失败", "error.cameraAccess": "无法访问摄像头", "error.micAccess": "无法访问麦克风", + + // Auth + "auth.subtitle": "AI 视觉对话助手", + "auth.login": "登录", + "auth.register": "注册", + "auth.username": "用户名", + "auth.username.placeholder": "3-64 个字符", + "auth.password": "密码", + "auth.password.placeholder": "8-72 个字符", + "auth.submitting": "请稍候...", + "auth.noAccount": "还没有账号?", + "auth.hasAccount": "已有账号?", + "auth.error.usernameLength": "用户名需要 3-64 个字符", + "auth.error.passwordLength": "密码需要 8-72 个字符", + "auth.logout": "退出登录", }; diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index e7cf6c5..13673f1 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -7,6 +7,9 @@ import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types const CONFIG_KEY = "camtalk:config"; const THEME_KEY = "camtalk:theme"; +const ACCESS_TOKEN_KEY = "camtalk:access_token"; +const REFRESH_TOKEN_KEY = "camtalk:refresh_token"; +const USER_KEY = "camtalk:user"; const DEFAULT_CONFIG: SessionConfig = { ttsEnabled: true, @@ -100,3 +103,70 @@ export function deleteSessionMessages(sessionId: string): void { localStorage.removeItem(SESSION_MSG_PREFIX + sessionId); } catch { /* ignore */ } } + +// ---- Auth Token 存储 ---- + +export interface StoredUser { + id: string; + username: string; + created_at: string; +} + +/** 保存 access token */ +export function saveAccessToken(token: string): void { + try { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + } catch { /* ignore */ } +} + +/** 获取 access token */ +export function loadAccessToken(): string | null { + try { + return localStorage.getItem(ACCESS_TOKEN_KEY); + } catch { + return null; + } +} + +/** 保存 refresh token */ +export function saveRefreshToken(token: string): void { + try { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + } catch { /* ignore */ } +} + +/** 获取 refresh token */ +export function loadRefreshToken(): string | null { + try { + return localStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } +} + +/** 保存用户信息 */ +export function saveUser(user: StoredUser): void { + try { + localStorage.setItem(USER_KEY, JSON.stringify(user)); + } catch { /* ignore */ } +} + +/** 获取用户信息 */ +export function loadUser(): StoredUser | null { + try { + const raw = localStorage.getItem(USER_KEY); + if (!raw) return null; + return JSON.parse(raw) as StoredUser; + } catch { + return null; + } +} + +/** 清除所有 auth 数据 */ +export function clearAuth(): void { + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); + } catch { /* ignore */ } +} diff --git a/frontend/src/lib/websocket.ts b/frontend/src/lib/websocket.ts index 7d8c947..356413a 100644 --- a/frontend/src/lib/websocket.ts +++ b/frontend/src/lib/websocket.ts @@ -24,6 +24,7 @@ export class CamTalkWebSocket { private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; private shouldReconnect = true; + private token: string | undefined; private messageHandlers = new Set(); private statusHandlers = new Set(); @@ -45,14 +46,16 @@ export class CamTalkWebSocket { return () => this.statusHandlers.delete(handler); } - /** 建立连接 */ - connect(): void { + /** 建立连接,可选传入 JWT token 用于认证 */ + connect(token?: string): void { if (this.ws?.readyState === WebSocket.OPEN) return; + this.token = token; this.shouldReconnect = true; this.setStatus("connecting"); - const ws = new WebSocket(WS_URL); + const url = token ? `${WS_URL}?token=${encodeURIComponent(token)}` : WS_URL; + const ws = new WebSocket(url); ws.onopen = () => { this.reconnectAttempt = 0; @@ -130,7 +133,7 @@ export class CamTalkWebSocket { this.reconnectTimer = setTimeout(() => { this.reconnectAttempt++; - this.connect(); + this.connect(this.token); }, totalDelay); } -- 2.49.1 From 542720695d3f6d0f401c7bf0ff637372f1fcfa86 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 18:40:37 +0800 Subject: [PATCH 43/46] =?UTF-8?q?feat:=20=E5=AF=B9=E6=8E=A5=20PostgreSQL?= =?UTF-8?q?=20=E5=AD=98=E5=82=A8=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 33 +++++++--- backend/internal/api/conversation.go | 36 ++++++++-- backend/internal/api/conversation_test.go | 2 +- backend/internal/store/migrate.go | 80 +++++++++++++++++++++++ backend/migrations/embed.go | 9 +++ docker-compose.yml | 22 +++++++ 6 files changed, 164 insertions(+), 18 deletions(-) create mode 100644 backend/internal/store/migrate.go create mode 100644 backend/migrations/embed.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 2a6275b..8accba9 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "github.com/hhs/camtalk/internal/session" "github.com/hhs/camtalk/internal/store" "github.com/hhs/camtalk/internal/ws" + migrations "github.com/hhs/camtalk/migrations" ) // Version 通过构建时 -ldflags 注入,如: @@ -50,27 +51,39 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + var userRepo store.UserRepository + var msgRepo store.MessageRepository + if cfg.Storage.Driver == "postgres" { pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN) if err != nil { logger.Log.Fatalw("failed to connect to postgres", "error", err) } defer pool.Close() - logger.Log.Infow("postgres connected", "driver", cfg.Storage.Driver) - // TODO: Phase 2 - 初始化 UserRepository 和 MessageRepository - _ = pool + + // 执行数据库迁移 + if err := store.RunMigrations(ctx, pool, migrations.FS); err != nil { + logger.Log.Fatalw("failed to run migrations", "error", err) + } + + userRepo = store.NewPgUserRepository(pool) + msgRepo = store.NewPgMessageRepository(pool) + logger.Log.Infow("postgres storage initialized", "driver", cfg.Storage.Driver) + } else { + userRepo = store.NewMemUserRepository() + logger.Log.Info("using in-memory storage") } - // 初始化 UserRepository(内存模式用于无 DB 场景) - var userRepo store.UserRepository - userRepo = store.NewMemUserRepository() - - // 初始化 Session Manager(MVP 默认内存实现) + // 初始化 Session Manager var sessionMgr session.Manager - // TODO: 当 Redis 配置非空时切换为 RedisManager + var sessionOpts []session.Option + if msgRepo != nil { + sessionOpts = append(sessionOpts, session.WithMessageRepository(msgRepo)) + } sessionMgr = session.NewMemoryManager( time.Duration(cfg.Session.TTL)*time.Minute, cfg.Session.MaxHistory, + sessionOpts..., ) defer sessionMgr.(*session.MemoryManager).Stop() @@ -141,7 +154,7 @@ func main() { authHandler.RegisterRoutes(apiGroup) // Conversation REST 端点 - convHandler := api.NewConversationHandler(sessionMgr, tokenMgr) + convHandler := api.NewConversationHandler(sessionMgr, tokenMgr, msgRepo) convHandler.RegisterRoutes(apiGroup) // WebSocket diff --git a/backend/internal/api/conversation.go b/backend/internal/api/conversation.go index f95e2c1..728cc9a 100644 --- a/backend/internal/api/conversation.go +++ b/backend/internal/api/conversation.go @@ -12,19 +12,23 @@ import ( apperr "github.com/hhs/camtalk/internal/errors" "github.com/hhs/camtalk/internal/models" "github.com/hhs/camtalk/internal/session" + "github.com/hhs/camtalk/internal/store" ) // ConversationHandler 提供对话相关的 REST 端点。 type ConversationHandler struct { sessionMgr session.Manager tokenMgr *auth.TokenManager + msgRepo store.MessageRepository // 可选,为 nil 时 fallback 到内存查询 } // NewConversationHandler 创建 ConversationHandler。 -func NewConversationHandler(sessionMgr session.Manager, tokenMgr *auth.TokenManager) *ConversationHandler { +// msgRepo 可选,为 nil 时消息查询走内存。 +func NewConversationHandler(sessionMgr session.Manager, tokenMgr *auth.TokenManager, msgRepo store.MessageRepository) *ConversationHandler { return &ConversationHandler{ sessionMgr: sessionMgr, tokenMgr: tokenMgr, + msgRepo: msgRepo, } } @@ -215,7 +219,7 @@ func (h *ConversationHandler) Delete(c *gin.Context) { // // 查询参数: // - limit: 返回消息数量上限,默认 50 -// - before: 消息偏移量(用于分页),返回此偏移量之前的消息 +// - before: 消息 ID 游标(用于分页),返回此 ID 之前的消息 func (h *ConversationHandler) GetMessages(c *gin.Context) { sessionID := c.Param("id") @@ -229,9 +233,27 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) { limit = 50 } - before, _ := strconv.Atoi(c.DefaultQuery("before", "0")) + beforeID, _ := strconv.ParseInt(c.DefaultQuery("before", "0"), 10, 64) - // 获取全量历史(内存实现中 history 是全量存储的) + // 优先从 PostgreSQL 查询(支持持久化后的全量历史) + if h.msgRepo != nil { + messages, err := h.msgRepo.GetMessages(c.Request.Context(), sessionID, limit, beforeID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "code": apperr.CodeInternalError, + "message": "failed to get messages", + }) + return + } + count, _ := h.msgRepo.GetMessageCount(c.Request.Context(), sessionID) + c.JSON(http.StatusOK, gin.H{ + "messages": messages, + "total": count, + }) + return + } + + // fallback:从内存查询 allMessages, err := h.sessionMgr.GetHistory(c.Request.Context(), sessionID, 0) if err != nil { if errors.Is(err, session.ErrSessionNotFound) { @@ -250,9 +272,9 @@ func (h *ConversationHandler) GetMessages(c *gin.Context) { total := len(allMessages) - // before > 0 表示取 before 之前的消息(不含 before 位置) - if before > 0 && before <= total { - allMessages = allMessages[:before] + // beforeID > 0 时表示偏移量(兼容旧接口语义) + if beforeID > 0 && int(beforeID) <= total { + allMessages = allMessages[:beforeID] } // 取最后 limit 条 diff --git a/backend/internal/api/conversation_test.go b/backend/internal/api/conversation_test.go index 6b873fc..468d172 100644 --- a/backend/internal/api/conversation_test.go +++ b/backend/internal/api/conversation_test.go @@ -93,7 +93,7 @@ func newConvTestRouter(mgr session.Manager) (*gin.Engine, *auth.TokenManager) { gin.SetMode(gin.TestMode) r := gin.New() tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) - h := api.NewConversationHandler(mgr, tm) + h := api.NewConversationHandler(mgr, tm, nil) h.RegisterRoutes(r.Group("/api")) return r, tm } diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go new file mode 100644 index 0000000..b3ea4ba --- /dev/null +++ b/backend/internal/store/migrate.go @@ -0,0 +1,80 @@ +package store + +import ( + "context" + "fmt" + "io/fs" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/hhs/camtalk/internal/logger" +) + +// RunMigrations 从给定的 fs.FS 中读取 *.up.sql 文件并按版本号顺序执行。 +// 已执行过的版本会跳过(通过 schema_migrations 表记录)。 +func RunMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS) error { + // 确保 schema_migrations 表存在 + if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + + // 收集所有 *.up.sql 文件 + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) + } + + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + + for _, name := range files { + // 从文件名提取版本号,如 "001_users.up.sql" → 1 + var version int + if _, err := fmt.Sscanf(name, "%d_", &version); err != nil { + return fmt.Errorf("parse version from %s: %w", name, err) + } + + // 检查是否已执行 + var exists bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, version, + ).Scan(&exists); err != nil { + return fmt.Errorf("check migration version %d: %w", version, err) + } + if exists { + logger.Log.Debugw("migration already applied", "version", version, "file", name) + continue + } + + // 读取并执行 + content, err := fs.ReadFile(fsys, name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + if _, err := pool.Exec(ctx, string(content)); err != nil { + return fmt.Errorf("execute migration %s: %w", name, err) + } + + // 记录已执行 + if _, err := pool.Exec(ctx, + `INSERT INTO schema_migrations (version) VALUES ($1)`, version, + ); err != nil { + return fmt.Errorf("record migration %d: %w", version, err) + } + + logger.Log.Infow("migration applied", "version", version, "file", name) + } + + return nil +} diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go new file mode 100644 index 0000000..9b6970b --- /dev/null +++ b/backend/migrations/embed.go @@ -0,0 +1,9 @@ +// Package migrations 提供数据库迁移 SQL 文件的嵌入式访问。 +package migrations + +import "embed" + +// FS 包含所有迁移 SQL 文件。 +// +//go:embed *.sql +var FS embed.FS diff --git a/docker-compose.yml b/docker-compose.yml index f5f15de..053f375 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,10 +19,32 @@ services: container_name: camtalk-backend environment: - APP_ENV=production + - CAMTALK_STORAGE_DRIVER=postgres + - CAMTALK_STORAGE_DSN=postgres://camtalk:camtalk123@postgres:5432/camtalk?sslmode=disable + - CAMTALK_AUTH_JWT_SECRET=78uWBBAF8XEQEotKDlrnlnd4y8i4WN3E4zXmNmC8BYQ= + depends_on: + - postgres networks: - camtalk-net restart: unless-stopped + postgres: + # 阿里云镜像,避免从 Docker Hub 拉取超时 + image: registry.cn-hangzhou.aliyuncs.com/library/postgres:15-alpine + container_name: camtalk-postgres + environment: + POSTGRES_USER: camtalk + POSTGRES_PASSWORD: camtalk123 + POSTGRES_DB: camtalk + volumes: + - pgdata:/var/lib/postgresql/data + networks: + - camtalk-net + restart: unless-stopped + +volumes: + pgdata: + networks: camtalk-net: driver: bridge -- 2.49.1 From 1109ab41f761bd85b94efce2954ce83af20f1d3a Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 18:42:47 +0800 Subject: [PATCH 44/46] =?UTF-8?q?refactor:=20=E5=B0=86=E9=80=80=E5=87=BA?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD=E4=BB=8E=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E6=A0=8F=E7=A7=BB=E8=87=B3=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 37 +++++++------------ frontend/src/App.tsx | 8 +--- frontend/src/components/ConfigPanel/index.tsx | 22 ++++++++++- frontend/src/lib/i18n/en-US.ts | 4 ++ frontend/src/lib/i18n/ja-JP.ts | 4 ++ frontend/src/lib/i18n/zh-CN.ts | 4 ++ 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index b31ff55..6d1d39c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1710,34 +1710,23 @@ body { color: var(--color-primary-hover); } -/* ---- User badge in header ---- */ +/* ---- Logout button in config panel ---- */ -.header__user { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.78rem; - color: var(--color-text-muted); -} - -.header__username { - font-weight: 500; - color: var(--color-text); -} - -.header__logout-btn { - background: none; - border: 1px solid var(--color-border); - color: var(--color-text-muted); - font-size: 0.72rem; - font-weight: 500; - padding: 4px 10px; +.config-logout-btn { + width: 100%; + margin-top: 12px; + padding: 9px 0; + border: 1px solid rgba(248, 113, 113, 0.25); border-radius: var(--radius-sm); + background: rgba(248, 113, 113, 0.08); + color: var(--color-error); + font-size: 0.82rem; + font-weight: 600; cursor: pointer; transition: all var(--transition-fast); } -.header__logout-btn:hover { - border-color: var(--color-error); - color: var(--color-error); +.config-logout-btn:hover { + background: rgba(248, 113, 113, 0.15); + border-color: rgba(248, 113, 113, 0.4); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd123bb..d0e9473 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -234,12 +234,6 @@ function AppContent() { {tr("app.title")}
-
- {user?.username} - -
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")} @@ -273,8 +267,10 @@ function AppContent() { setShowConfig(false)} /> )} diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx index 9523ec6..031106e 100644 --- a/frontend/src/components/ConfigPanel/index.tsx +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -9,12 +9,14 @@ import type { SessionConfig, Theme } from "../../types"; interface ConfigPanelProps { config: SessionConfig; theme: Theme; + username?: string; onUpdate: (partial: Partial) => void; onThemeChange: (theme: Theme) => void; + onLogout?: () => void; onClose: () => void; } -export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) { +export function ConfigPanel({ config, theme, username, onUpdate, onThemeChange, onLogout, onClose }: ConfigPanelProps) { const { t } = useI18n(); return ( @@ -88,6 +90,24 @@ export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }:
+ + {username && onLogout && ( +
+
{t("settings.account")}
+
+
+ {t("settings.account.user")} + {username} +
+
+ +
+ )}
diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index 9f47068..835c5fa 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -145,4 +145,8 @@ export const enUS: TranslationMap = { "auth.error.usernameLength": "Username must be 3-64 characters", "auth.error.passwordLength": "Password must be 8-72 characters", "auth.logout": "Sign Out", + + // Account (in settings) + "settings.account": "Account", + "settings.account.user": "Current user", }; diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 34abfbe..74f4764 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -145,4 +145,8 @@ export const jaJP: TranslationMap = { "auth.error.usernameLength": "ユーザー名は3〜64文字で入力してください", "auth.error.passwordLength": "パスワードは8〜72文字で入力してください", "auth.logout": "ログアウト", + + // Account (in settings) + "settings.account": "アカウント", + "settings.account.user": "現在のユーザー", }; diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index 337fc95..8fc5716 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -145,4 +145,8 @@ export const zhCN: TranslationMap = { "auth.error.usernameLength": "用户名需要 3-64 个字符", "auth.error.passwordLength": "密码需要 8-72 个字符", "auth.logout": "退出登录", + + // Account (in settings) + "settings.account": "账号", + "settings.account.user": "当前用户", }; -- 2.49.1 From 966b30218aaf9970895becbb3713f95a8b0e631d Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 18:54:18 +0800 Subject: [PATCH 45/46] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20sessions=20?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=8C=E6=94=AF=E6=8C=81=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 5 + backend/internal/session/memory.go | 201 +++++++++++++++++++---- backend/internal/store/session.go | 47 ++++++ backend/internal/store/session_pg.go | 146 ++++++++++++++++ backend/migrations/003_sessions.down.sql | 1 + backend/migrations/003_sessions.up.sql | 11 ++ 6 files changed, 376 insertions(+), 35 deletions(-) create mode 100644 backend/internal/store/session.go create mode 100644 backend/internal/store/session_pg.go create mode 100644 backend/migrations/003_sessions.down.sql create mode 100644 backend/migrations/003_sessions.up.sql diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8accba9..cdca165 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -53,6 +53,7 @@ func main() { var userRepo store.UserRepository var msgRepo store.MessageRepository + var sessRepo store.SessionRepository if cfg.Storage.Driver == "postgres" { pool, err := store.NewPostgresPool(ctx, cfg.Storage.DSN) @@ -68,6 +69,7 @@ func main() { userRepo = store.NewPgUserRepository(pool) msgRepo = store.NewPgMessageRepository(pool) + sessRepo = store.NewPgSessionRepository(pool) logger.Log.Infow("postgres storage initialized", "driver", cfg.Storage.Driver) } else { userRepo = store.NewMemUserRepository() @@ -80,6 +82,9 @@ func main() { if msgRepo != nil { sessionOpts = append(sessionOpts, session.WithMessageRepository(msgRepo)) } + if sessRepo != nil { + sessionOpts = append(sessionOpts, session.WithSessionRepository(sessRepo)) + } sessionMgr = session.NewMemoryManager( time.Duration(cfg.Session.TTL)*time.Minute, cfg.Session.MaxHistory, diff --git a/backend/internal/session/memory.go b/backend/internal/session/memory.go index bfb232c..8ee3f3b 100644 --- a/backend/internal/session/memory.go +++ b/backend/internal/session/memory.go @@ -2,6 +2,7 @@ package session import ( "context" + "encoding/json" "sort" "sync" "time" @@ -34,7 +35,8 @@ type MemoryManager struct { ttl time.Duration maxHistory int stopCleaner chan struct{} - msgRepo store.MessageRepository // 可选,消息持久化(Write-Through) + msgRepo store.MessageRepository // 可选,消息持久化(Write-Through) + sessRepo store.SessionRepository // 可选,会话持久化(Write-Through) } // Option MemoryManager 的函数式选项。 @@ -47,6 +49,13 @@ func WithMessageRepository(repo store.MessageRepository) Option { } } +// WithSessionRepository 注入会话持久化仓库,启用会话元数据 Write-Through 模式。 +func WithSessionRepository(repo store.SessionRepository) Option { + return func(m *MemoryManager) { + m.sessRepo = repo + } +} + // NewMemoryManager 创建内存版 SessionManager。 // ttl 为会话过期时间,maxHistory 为对话历史上限(0 表示使用默认值 20)。 // opts 为可选配置,如 WithMessageRepository 启用消息持久化。 @@ -114,9 +123,8 @@ func (m *MemoryManager) isExpired(entry *sessionEntry) bool { } // Create 创建新会话。userID 为空表示匿名会话。 -func (m *MemoryManager) Create(_ context.Context, userID string, config models.SessionConfig) (string, error) { +func (m *MemoryManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) { m.mu.Lock() - defer m.mu.Unlock() id := uuid.New().String() now := time.Now() @@ -132,73 +140,181 @@ func (m *MemoryManager) Create(_ context.Context, userID string, config models.S history: make([]models.Message, 0), lastActive: now, } + m.mu.Unlock() + + // Write-Through:异步写 PG + if m.sessRepo != nil { + go func() { + cfgJSON, _ := json.Marshal(config) + if err := m.sessRepo.Save(ctx, store.SessionRecord{ + ID: id, UserID: userID, Title: models.DefaultSessionTitle, + Config: cfgJSON, CreatedAt: now, UpdatedAt: now, + }); err != nil { + logger.Log.Warnw("persist session failed", "session", id, "error", err) + } + }() + } logger.Log.Debugw("session created", "session", id, "user_id", userID) return id, nil } -// Get 获取会话。 -func (m *MemoryManager) Get(_ context.Context, sessionID string) (*models.Session, error) { +// Get 获取会话。内存中不存在时,尝试从 PG 加载(透明恢复)。 +func (m *MemoryManager) Get(ctx context.Context, sessionID string) (*models.Session, error) { m.mu.RLock() - defer m.mu.RUnlock() - entry, ok := m.sessions[sessionID] - if !ok || m.isExpired(entry) { - return nil, ErrSessionNotFound + if ok && !m.isExpired(entry) { + sess := entry.session + m.mu.RUnlock() + return &sess, nil + } + m.mu.RUnlock() + + // 内存未命中,尝试从 PG 加载 + if m.sessRepo != nil { + rec, err := m.sessRepo.FindByID(ctx, sessionID) + if err != nil { + return nil, ErrSessionNotFound + } + sess := m.recordToSession(rec) + // 加载到内存(含消息历史) + if m.msgRepo != nil { + _ = m.LoadSessionFromRepo(ctx, sess) + } else { + _ = m.LoadSession(sess, nil) + } + return sess, nil } - sess := entry.session // 复制一份返回 - return &sess, nil + return nil, ErrSessionNotFound } // UpdateConfig 更新会话配置。 -func (m *MemoryManager) UpdateConfig(_ context.Context, sessionID string, patch models.SessionConfigPatch) error { +func (m *MemoryManager) UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error { m.mu.Lock() - defer m.mu.Unlock() entry, ok := m.sessions[sessionID] if !ok || m.isExpired(entry) { + m.mu.Unlock() return ErrSessionNotFound } patch.Apply(&entry.session.Config) entry.lastActive = time.Now() + cfg := entry.session.Config + m.mu.Unlock() + + // Write-Through:异步更新 PG + if m.sessRepo != nil { + go func() { + cfgJSON, _ := json.Marshal(cfg) + if err := m.sessRepo.UpdateConfig(ctx, sessionID, cfgJSON); err != nil { + logger.Log.Warnw("update session config in DB failed", "session", sessionID, "error", err) + } + }() + } logger.Log.Debugw("session config updated", "session", sessionID) return nil } // UpdateTitle 更新会话标题。 -func (m *MemoryManager) UpdateTitle(_ context.Context, sessionID string, title string) error { +func (m *MemoryManager) UpdateTitle(ctx context.Context, sessionID string, title string) error { m.mu.Lock() - defer m.mu.Unlock() entry, ok := m.sessions[sessionID] if !ok || m.isExpired(entry) { + m.mu.Unlock() return ErrSessionNotFound } entry.session.Title = title entry.session.UpdatedAt = time.Now() entry.lastActive = time.Now() + m.mu.Unlock() + + // Write-Through:异步更新 PG + if m.sessRepo != nil { + go func() { + if err := m.sessRepo.UpdateTitle(ctx, sessionID, title); err != nil { + logger.Log.Warnw("update session title in DB failed", "session", sessionID, "error", err) + } + }() + } logger.Log.Debugw("session title updated", "session", sessionID, "title", title) return nil } // ListByUser 获取用户的对话列表(分页,按 UpdatedAt 降序)。 +// 若配置了 SessionRepository,从 PG 查询(包含内存中已过期的会话)。 // 若配置了 MessageRepository,消息统计从 PostgreSQL 聚合查询(更准确)。 func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + + // 优先从 PG 查询会话列表(包含已过期的会话) + if m.sessRepo != nil { + recs, total, err := m.sessRepo.FindByUser(ctx, userID, page, size) + if err != nil { + logger.Log.Warnw("list sessions from DB failed, falling back to in-memory", "error", err) + return m.listByUserFromMemory(ctx, userID, page, size) + } + + list := make([]ConversationSummary, 0, len(recs)) + var sessionIDs []string + for _, rec := range recs { + list = append(list, ConversationSummary{ + ID: rec.ID, + Title: rec.Title, + UpdatedAt: rec.UpdatedAt, + }) + sessionIDs = append(sessionIDs, rec.ID) + } + + // 用内存中的消息数填充 + m.mu.RLock() + for i := range list { + if entry, ok := m.sessions[list[i].ID]; ok { + list[i].MessageCount = len(entry.history) + if len(entry.history) > 0 { + list[i].LastMessage = entry.history[len(entry.history)-1].Content + } + } + } + m.mu.RUnlock() + + // 从 PG 获取更准确的消息统计 + if m.msgRepo != nil && len(sessionIDs) > 0 { + if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil { + for i := range list { + if s, ok := stats[list[i].ID]; ok { + list[i].LastMessage = s.LastMessage + list[i].MessageCount = s.MessageCount + } + } + } + } + + return list, total, nil + } + + // fallback:纯内存查询 + return m.listByUserFromMemory(ctx, userID, page, size) +} + +// listByUserFromMemory 从内存中获取用户的对话列表(无 PG 时的 fallback)。 +func (m *MemoryManager) listByUserFromMemory(ctx context.Context, userID string, page, size int) ([]ConversationSummary, int, error) { m.mu.RLock() - // 收集该用户的所有 session var list []ConversationSummary var sessionIDs []string for _, entry := range m.sessions { - if entry.session.UserID != userID { - continue - } - if m.isExpired(entry) { + if entry.session.UserID != userID || m.isExpired(entry) { continue } summary := ConversationSummary{ @@ -206,7 +322,6 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz Title: entry.session.Title, UpdatedAt: entry.lastActive, } - // 先用内存值填充,后续可能被 PG 统计覆盖 summary.MessageCount = len(entry.history) if len(entry.history) > 0 { summary.LastMessage = entry.history[len(entry.history)-1].Content @@ -216,7 +331,7 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz } m.mu.RUnlock() - // 若配置了 msgRepo,从 PostgreSQL 获取更准确的消息统计 + // 从 PG 获取更准确的消息统计 if m.msgRepo != nil && len(sessionIDs) > 0 { if stats, err := m.msgRepo.GetSessionMessageStats(ctx, sessionIDs); err == nil { for i := range list { @@ -225,25 +340,14 @@ func (m *MemoryManager) ListByUser(ctx context.Context, userID string, page, siz list[i].MessageCount = s.MessageCount } } - } else { - logger.Log.Warnw("get session message stats failed, falling back to in-memory", "error", err) } } - // 按 UpdatedAt 降序排序 sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt.After(list[j].UpdatedAt) }) total := len(list) - - // 分页 - if page <= 0 { - page = 1 - } - if size <= 0 { - size = 20 - } start := (page - 1) * size if start >= total { return []ConversationSummary{}, total, nil @@ -425,15 +529,26 @@ func (m *MemoryManager) Touch(_ context.Context, sessionID string) error { } // Destroy 显式销毁会话。 -func (m *MemoryManager) Destroy(_ context.Context, sessionID string) error { +func (m *MemoryManager) Destroy(ctx context.Context, sessionID string) error { m.mu.Lock() - defer m.mu.Unlock() if _, ok := m.sessions[sessionID]; !ok { + m.mu.Unlock() return ErrSessionNotFound } delete(m.sessions, sessionID) + m.mu.Unlock() + + // Write-Through:异步删除 PG + if m.sessRepo != nil { + go func() { + if err := m.sessRepo.Delete(ctx, sessionID); err != nil { + logger.Log.Warnw("delete session from DB failed", "session", sessionID, "error", err) + } + }() + } + logger.Log.Debugw("session destroyed", "session", sessionID) return nil } @@ -452,3 +567,19 @@ func (m *MemoryManager) ActiveCount() int { } return count } + +// recordToSession 将 store.SessionRecord 转换为 models.Session。 +func (m *MemoryManager) recordToSession(rec *store.SessionRecord) *models.Session { + cfg := models.DefaultConfig() + if len(rec.Config) > 0 { + _ = json.Unmarshal(rec.Config, &cfg) + } + return &models.Session{ + ID: rec.ID, + UserID: rec.UserID, + Title: rec.Title, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + Config: cfg, + } +} diff --git a/backend/internal/store/session.go b/backend/internal/store/session.go new file mode 100644 index 0000000..cbe8c9e --- /dev/null +++ b/backend/internal/store/session.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + "errors" + "time" +) + +var ( + // ErrSessionNotFound 会话不存在。 + ErrSessionNotFound = errors.New("session not found") +) + +// SessionRepository 会话持久化接口。 +type SessionRepository interface { + // Save 创建或更新会话(UPSERT)。 + Save(ctx context.Context, s SessionRecord) error + + // FindByID 根据 ID 查询会话。 + FindByID(ctx context.Context, id string) (*SessionRecord, error) + + // FindByUser 查询用户的会话列表(分页,按 updated_at 降序)。 + // 返回 (列表, 总数, error)。 + FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) + + // UpdateTitle 更新会话标题。 + UpdateTitle(ctx context.Context, id string, title string) error + + // UpdateConfig 更新会话配置。 + UpdateConfig(ctx context.Context, id string, configJSON []byte) error + + // Touch 刷新 updated_at。 + Touch(ctx context.Context, id string) error + + // Delete 删除会话。 + Delete(ctx context.Context, id string) error +} + +// SessionRecord 持久化会话模型(store 层)。 +type SessionRecord struct { + ID string + UserID string + Title string + Config []byte // JSON 编码的 SessionConfig + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/backend/internal/store/session_pg.go b/backend/internal/store/session_pg.go new file mode 100644 index 0000000..53aff40 --- /dev/null +++ b/backend/internal/store/session_pg.go @@ -0,0 +1,146 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PgSessionRepository 基于 PostgreSQL 的 SessionRepository 实现。 +type PgSessionRepository struct { + pool *pgxpool.Pool +} + +// NewPgSessionRepository 创建 PgSessionRepository。 +func NewPgSessionRepository(pool *pgxpool.Pool) *PgSessionRepository { + return &PgSessionRepository{pool: pool} +} + +func (r *PgSessionRepository) Save(ctx context.Context, s SessionRecord) error { + _, err := r.pool.Exec(ctx, + `INSERT INTO sessions (id, user_id, title, config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + config = EXCLUDED.config, + updated_at = EXCLUDED.updated_at`, + s.ID, s.UserID, s.Title, s.Config, s.CreatedAt, s.UpdatedAt, + ) + return err +} + +func (r *PgSessionRepository) FindByID(ctx context.Context, id string) (*SessionRecord, error) { + var s SessionRecord + err := r.pool.QueryRow(ctx, + `SELECT id, user_id, title, config, created_at, updated_at + FROM sessions WHERE id = $1`, id, + ).Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSessionNotFound + } + if err != nil { + return nil, err + } + return &s, nil +} + +func (r *PgSessionRepository) FindByUser(ctx context.Context, userID string, page, size int) ([]SessionRecord, int, error) { + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + offset := (page - 1) * size + + // 查询总数 + var total int + if err := r.pool.QueryRow(ctx, + `SELECT COUNT(*) FROM sessions WHERE user_id = $1`, userID, + ).Scan(&total); err != nil { + return nil, 0, err + } + + // 查询列表 + rows, err := r.pool.Query(ctx, + `SELECT id, user_id, title, config, created_at, updated_at + FROM sessions + WHERE user_id = $1 + ORDER BY updated_at DESC + LIMIT $2 OFFSET $3`, + userID, size, offset, + ) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var list []SessionRecord + for rows.Next() { + var s SessionRecord + if err := rows.Scan(&s.ID, &s.UserID, &s.Title, &s.Config, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, 0, err + } + list = append(list, s) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + return list, total, nil +} + +func (r *PgSessionRepository) UpdateTitle(ctx context.Context, id string, title string) error { + tag, err := r.pool.Exec(ctx, + `UPDATE sessions SET title = $2, updated_at = NOW() WHERE id = $1`, + id, title, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} + +func (r *PgSessionRepository) UpdateConfig(ctx context.Context, id string, configJSON []byte) error { + tag, err := r.pool.Exec(ctx, + `UPDATE sessions SET config = $2, updated_at = NOW() WHERE id = $1`, + id, configJSON, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} + +func (r *PgSessionRepository) Touch(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, + `UPDATE sessions SET updated_at = NOW() WHERE id = $1`, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} + +func (r *PgSessionRepository) Delete(ctx context.Context, id string) error { + tag, err := r.pool.Exec(ctx, + `DELETE FROM sessions WHERE id = $1`, id, + ) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil +} diff --git a/backend/migrations/003_sessions.down.sql b/backend/migrations/003_sessions.down.sql new file mode 100644 index 0000000..63d205d --- /dev/null +++ b/backend/migrations/003_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS sessions; diff --git a/backend/migrations/003_sessions.up.sql b/backend/migrations/003_sessions.up.sql new file mode 100644 index 0000000..0c3d7fa --- /dev/null +++ b/backend/migrations/003_sessions.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + title VARCHAR(256) NOT NULL DEFAULT '新对话', + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user_updated ON sessions (user_id, updated_at DESC); -- 2.49.1 From 386c918591357fffca7b146133f640164702e02c Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sun, 14 Jun 2026 19:00:06 +0800 Subject: [PATCH 46/46] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20PostgreSQL?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E5=B9=B6=E6=8C=82=E8=BD=BD=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 053f375..acbb3c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,7 @@ services: POSTGRES_DB: camtalk volumes: - pgdata:/var/lib/postgresql/data + - ./backend/migrations:/docker-entrypoint-initdb.d networks: - camtalk-net restart: unless-stopped -- 2.49.1