Files
CamTalk/docs/02-接口文档.md
hhs a04275cc76 docs: 按功能模块重构文档结构
- 新建 01-架构设计.md:合并项目概述+系统架构+持久化设计,含 Mermaid 架构图、模块图、时序图、ER 图、部署图
- 新建 02-接口文档.md:合并接口文档+持久化 API+用户模块 API,统一格式去重
- 重编号 03~09,去掉状态标注,规划中功能标记为待实现
- 删除 PLAN_BACKEND.md、PLAN_USER_MODULE.md 及冗余文档
2026-06-19 15:31:52 +08:00

38 KiB
Raw Blame History

接口文档

概述

前后端通信接口定义。以 WebSocket 承载实时对话REST 端点支撑基础运维。持久化通过 PostgreSQL 实现MemoryManager 支持 Write-Through 模式。

设计原则

  • WebSocket 为主:所有对话数据走 WebSocket
  • REST 为辅:仅用于健康检查、认证、对话管理等低频操作
  • 接口先行:先定义契约,再填充实现——前后端可并行开发

接口全景

浏览器                    Go Gateway :8080
  WebSocket Client  <-->  /ws?token=<jwt>             (实时对话,需 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

一、WebSocket 协议

连接地址:ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>

参数 必填 说明
token JWT access_token缺失或无效时返回 401
conversation_id 恢复已有对话;省略则创建新对话

消息格式约定

所有 WebSocket 消息均为 JSON 文本帧,统一结构:

interface WsMessage {
  type: string;        // 消息类型,必填
  request_id?: string; // 可选,用于请求-响应关联
  timestamp?: number;  // 可选,毫秒时间戳
  [key: string]: any;  // 类型特定字段
}

客户端 → 服务端消息

query — 发起一次视觉对话

用户说完话后,客户端同时发送当前图像帧和语音片段。也支持文本输入模式(手动输入文字时跳过语音识别):

interface QueryMessage {
  type: "query";
  request_id: string;    // 客户端生成的 UUID
  image: string;         // Base64 编码的 JPEG 图像(不含 data: 前缀)
  audio: string;         // Base64 编码的音频片段PCM 16kHz文本输入时为空字符串
  text?: string;         // 用户手动输入的文本(有值时跳过 STT直接使用此文本
  mime_type?: string;    // 音频格式,默认 "audio/pcm"
}

为什么图像和音频放在同一条消息里?因为 VAD 检测到用户说完话时,需要同时捕获"此刻的画面"和"说的话",拆成两条消息会增加时序同步的复杂度。

文本输入模式:当用户关闭麦克风后,可通过对话框手动输入文字。此时 text 字段携带用户输入,audio 为空字符串,服务端跳过 STT 直接使用 text 进行 LLM 推理。

config — 更新会话配置

interface ConfigMessage {
  type: "config";
  payload: {
    tts_enabled?: boolean;         // 是否开启语音合成,默认 true
    detail_level?: "low" | "high"; // 图像精度,默认 "low"
    language?: string;             // 交互语言,默认 "zh-CN"
    scenario?: string;             // 场景模式free_chat / interviewer / english_teacher / debate / interpreter
  };
}

interrupt — 打断当前回复

interface InterruptMessage {
  type: "interrupt";
  request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
}

ping — 心跳保活

interface PingMessage {
  type: "ping";
}

服务端 → 客户端消息

connected — 连接建立确认

interface ConnectedMessage {
  type: "connected";
  session_id: string;         // 服务端生成的会话 ID
  conversation_id: string;    // 同 session_id便于前端统一使用
  server_version: string;     // 服务端版本号,如 "0.1.0"
}

stt_result — 语音识别结果

interface STTResultMessage {
  type: "stt_result";
  request_id: string;
  text: string;        // 识别出的用户语音文本
  is_final: boolean;   // 是否为最终结果
}

llm_chunk — LLM 流式输出片段

interface LLMChunkMessage {
  type: "llm_chunk";
  request_id: string;
  delta: string;       // 本次增量文本
  role: "assistant";
}

llm_done — LLM 输出完成

interface LLMDoneMessage {
  type: "llm_done";
  request_id: string;
  full_text: string;     // 完整回复文本
  tokens_used: {
    prompt: number;
    completion: number;
    total: number;
  };
  model: string;         // 实际使用的模型名
  latency_ms: number;    // 端到端延迟(毫秒)
}

tts_audio — TTS 音频流片段

interface TTSAudioMessage {
  type: "tts_audio";
  request_id: string;
  audio: string;         // Base64 编码的音频片段
  mime_type: string;     // "audio/mp3"
  is_last: boolean;      // 当前句子的音频是否完整(每句结束时为 true
  final: boolean;        // 整轮 TTS 是否结束(所有句子合成完毕后为 true
}

字段语义

  • is_last: 每个句子合成完毕后为 true,前端收到此信号即可将该句子加入播放队列。每句 TTS 音频由一次独立的 API 调用生成,对应一个 tts_audio 消息。
  • final: 所有句子合成完毕后为 true(此时 audio 为空字符串),用于前端判断本轮 TTS 已全部到齐。

音频格式规范(前端播放依赖此约定):

属性 说明
编码 audio/mp3MP3 浏览器 <audio> 原生支持
采样率 24kHz OpenAI TTS 默认
声道 单声道 语音不需要立体声
传输 Base64 编码的 MP3 片段 每个 tts_audio 消息携带一个句子的音频
切片粒度 按句子切分 LLM 输出中按 。!?\n 等标点切分,每个句子独立合成

流式播放时序tts_audio 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。

前端播放实现要点

  1. 排队播放:收到 is_last: true 时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在 onended 回调中自动衔接。
  2. 错误容错:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
  3. 打断清理:收到 interrupt 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
  4. 类型锁定mime_type 字段固定为 "audio/mp3",前端解码时直接使用,无需运行时判断。
// 前端播放器伪代码
class AudioPlayer {
  private sentenceChunks: string[] = [];  // 当前句子的音频片段缓冲
  private queue: string[] = [];           // 已就绪的句子 Blob URL 队列

  enqueue(base64: string, isLast: boolean) {
    this.sentenceChunks.push(base64);
    if (isLast) {
      const url = decodeBase64Audio(this.sentenceChunks.join(""), "audio/mp3");
      this.sentenceChunks = [];
      this.queue.push(url);
      if (this.queue.length === 1) this.playNext();
    }
  }

  private playNext() {
    if (this.queue.length === 0) return;
    const audio = new Audio(this.queue.shift()!);
    audio.onended = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
    audio.onerror = () => { URL.revokeObjectURL(audio.src); this.playNext(); };
    audio.play();
  }

  clear() { this.queue.forEach(url => URL.revokeObjectURL(url)); this.queue = []; this.sentenceChunks = []; }
}

error — 错误通知

interface ErrorMessage {
  type: "error";
  request_id?: string;
  code: string;          // 错误码,见下方错误码表
  message: string;       // 人类可读的错误描述
}

pong — 心跳响应

interface PongMessage {
  type: "pong";
}

消息流时序

语音模式(麦克风开启):

Client                          Server
  |                               |
  |-- query {image, audio} ------>|
  |<-- stt_result {text} ---------|  (语音识别)
  |                               |
  |<-- llm_chunk {delta: "这"} ---|  (LLM 流式输出)
  |<-- llm_chunk {delta: "是一"} -|
  |<-- llm_chunk {delta: "朵花"} -|
  |<-- llm_done {full_text} ------|
  |                               |
  |<-- tts_audio {audio} ---------|  (TTS 音频流)
  |<-- tts_audio {is_last: true} -|  (句子完成)
  |<-- tts_audio {final: true} ---|  (TTS 全部结束)

文本输入模式(麦克风关闭,手动输入文字):

Client                          Server
  |                               |
  |-- query {image, text} ------->|  (跳过 STT)
  |<-- stt_result {text} ---------|  (回显用户文本)
  |                               |
  |<-- llm_chunk {delta: "好的"} -|  (LLM 流式输出)
  |<-- llm_chunk {delta: ",我"} -|
  |<-- llm_done {full_text} ------|
  |                               |
  |<-- tts_audio {audio} ---------|  (TTS 音频流)
  |<-- tts_audio {is_last: true} -|  (句子完成)
  |<-- tts_audio {final: true} ---|  (TTS 全部结束)

二、REST API

通用约定

认证方式

需要认证的接口在请求头携带 JWT access token

Authorization: Bearer <access_token>

未认证或 token 过期时返回 401 Unauthorized

错误响应格式

所有错误响应统一结构:

interface ApiError {
  code: string;     // 机器可读错误码
  message: string;  // 人类可读描述
}

示例:

{
  "code": "USERNAME_TAKEN",
  "message": "username already taken"
}

输入校验规则

字段 规则
username 3-64 字符,仅允许字母、数字、下划线
password 8-72 字符

认证接口(/api/auth

注册

POST /api/auth/register
Content-Type: application/json

请求体

interface RegisterRequest {
  username: string;  // 3-64 字符
  password: string;  // 8-72 字符
}

成功响应 201 Created

interface AuthResponse {
  user: {
    id: string;          // UUID
    username: string;
    created_at: string;  // ISO 8601
  };
  access_token: string;   // JWT15 分钟有效
  refresh_token: string;  // JWT7 天有效
}

错误响应

状态码 code 场景
400 INVALID_INPUT 用户名/密码不符合校验规则
409 USERNAME_TAKEN 用户名已存在

登录

POST /api/auth/login
Content-Type: application/json

请求体

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

请求体

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

请求体

interface LogoutRequest {
  refresh_token: string;  // 要废弃的 refresh_token
}

成功响应 204 No Content(无响应体)。

错误响应

状态码 code 场景
401 INVALID_TOKEN access_token 无效或已过期

对话接口(/api/conversations

以下所有接口均需认证(Authorization: Bearer <access_token>),省略不重复标注。

对话列表

GET /api/conversations?page=1&size=20

查询参数

参数 类型 默认值 说明
page int 1 页码,从 1 开始
size int 20 每页条数,最大 50

成功响应 200 OK

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最后活跃时间
}

创建对话

POST /api/conversations
Content-Type: application/json

请求体(可选,全部有默认值):

interface CreateConversationRequest {
  config?: {
    tts_enabled?: boolean;         // 默认 true
    detail_level?: "low" | "high"; // 默认 "low"
    language?: string;             // 默认 "zh-CN"
  };
}

成功响应 201 Created

interface ConversationDetail {
  id: string;
  title: string;
  config: {
    tts_enabled: boolean;
    detail_level: "low" | "high";
    language: string;
  };
  created_at: string;
}

获取对话详情

GET /api/conversations/:id

成功响应 200 OK:同 ConversationDetail 结构。

错误响应

状态码 code 场景
404 SESSION_NOT_FOUND 对话不存在或不属于当前用户

更新对话标题

PATCH /api/conversations/:id
Content-Type: application/json

请求体

interface UpdateTitleRequest {
  title: string;  // 1-100 字符
}

成功响应 200 OK

{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "title": "新的自定义标题"
}

错误响应

状态码 code 场景
400 INVALID_INPUT title 为空或超长
404 SESSION_NOT_FOUND 对话不存在或不属于当前用户

删除对话

DELETE /api/conversations/:id

成功响应 204 No Content(无响应体)。

获取对话消息

GET /api/conversations/:id/messages?limit=50&before=<message_id>

查询参数

参数 类型 默认值 说明
limit int 50 返回条数,最大 100
before int64 游标分页:返回此 message_id 之前的消息(不含)

成功响应 200 OK

interface MessagesResponse {
  messages: StoredMessage[];
  has_more: boolean;
}

interface StoredMessage {
  id: number;           // 自增 ID用于游标分页
  role: "user" | "assistant";
  content: string;
  tokens_used: number;
  created_at: string;   // ISO 8601
}

分页用法:首次请求不带 before,获取最新消息。滚动到顶部时,取当前列表最小的 id 作为 before 参数请求更早的消息。


健康检查

GET /api/health

无需认证。

成功响应 200 OK

{
  "status": "ok",
  "version": "0.1.0",
  "uptime_seconds": 3600,
  "active_sessions": 42
}

预留端点(待实现)

端点 方法 用途
/api/usage GET 查询用量统计
/api/users/{id}/preferences GET/PUT 用户偏好管理

三、AI 服务层接口

Go 网关内部与外部 AI 服务STT、LLM、TTS的调用契约。通过 OpenAI 兼容接口可灵活切换到其他服务商。

STT 服务接口

语音识别:接收前端采集的音频,返回识别文本。

// Service 语音识别服务契约。
type Service interface {
    // Recognize 识别一段完整音频,返回最终文本。
    Recognize(ctx context.Context, audio []byte, opts Options) (string, error)
}

// Options 语音识别参数。
type Options struct {
    Encoding   string // "pcm_s16le" — 前端 VAD 输出格式
    SampleRate int    // 16000 — 前端麦克风采样率
    Language   string // "zh-CN"
}

已实现 Provider

Provider 连接方式 说明
Deepgram默认 WebSocket wss://api.deepgram.com/v1/listen 流式识别,延迟极低,模型 nova-2
MiMo ASR HTTP POST OpenAI 兼容 /chat/completions 国产替代PCM 自动转 WAV支持 zh/en/auto

LLM 服务接口

多模态推理:接收图像 + 文本 + 对话历史,流式返回回复。

// Service 多模态大模型服务契约。
type Service interface {
    // ChatStream 流式推理,返回增量文本的 channel。
    ChatStream(ctx context.Context, req Request) (<-chan Chunk, error)
}

// Request 推理请求。
type Request struct {
    Image         []byte            // JPEG 图片(已从 Base64 解码)
    Text          string            // 用户语音识别后的文本
    History       []models.Message  // 最近 N 轮对话历史
    Language      string            // "zh-CN"
    SystemPrompt  string            // 系统提示词(含场景 prompt
}

// Chunk 流式推理的一个增量片段。
type Chunk struct {
    Delta      string
    Done       bool
    TokensUsed *TokenUsage  // 仅 Done=true 时有值
    Model      string       // 实际使用的模型名
}

接入约定

  • 端点:POST {endpoint}/chat/completions,通过配置切换
  • 图片传入:image_url 字段使用 data:image/jpeg;base64,... 格式
  • 流式响应:stream: true,通过 SSE 逐 chunk 返回
  • 超时10 秒,超时返回 LLM_TIMEOUT 错误
  • 系统提示词根据语言和场景scenario动态构建

TTS 服务接口

语音合成:接收文本流,输出音频 chunk 流。

// Service 语音合成服务契约。
type Service interface {
    // SynthesizeStream 流式合成。
    // textStream 接收句子级文本(由 Orchestrator 的句子切分器产出),
    // 返回的 channel 持续输出 MP3 音频 chunk。
    SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error)
}

// Options 合成参数。
type Options struct {
    Voice      string  // 语音名称
    Speed      float64 // 1.0 为正常语速
    OutputFmt  string  // "mp3"
    SampleRate int     // 24000
}

已实现 Provider

Provider 端点 说明
OpenAI TTS默认 POST /audio/speech 逐句合成,返回 MP3 流
MiMo TTS POST /chat/completions 国产替代base64 音频响应

四、AI 编排器Orchestrator

编排策略:句子级流式并行

核心矛盾LLM 流式输出逐 tokenTTS 需要完整句子才能合成。解法:句子切分器 + 管道并行

LLM 流式输出:  "这" "是一" "朵红色" "的花。" "它看起" "来很美" "丽。"
                ↓
        ┌── 句子检测器(按 。!?\n 切分)──┐
        ↓                                 ↓
  句子1: "这是一朵红色的花。"          句子2: "它看起来很美丽。"
        ↓                                 ↓
     TTS 合成                          TTS 合成
        ↓                                 ↓
  音频 chunk → 推送前端             音频 chunk → 推送前端

时序保证

  • llm_chunk 消息一定先于对应句子的 tts_audio 到达客户端
  • 用户先看到文字,紧接着听到语音(感知延迟 < 0.5 秒)

Orchestrator 接口

type Orchestrator interface {
    ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
        history []models.Message, sender Sender) error
}

type Sender interface {
    SendSTTResult(result models.WsSTTResult) error
    SendLLMChunk(chunk models.WsLLMChunk) error
    SendLLMDone(done models.WsLLMDone) error
    SendTTSAudio(audio models.WsTTSAudio) error
    SendError(err models.WsError) error
}

Pipeline 实现流程

  1. Base64 解码音频/图片
  2. 调用 stt.Recognize() → 发送 stt_result
  3. 调用 llm.ChatStream() 获取流式输出goroutine 消费 token → 发送 llm_chunk + 句子切分
  4. 另一 goroutine 从句子 channel 读取 → 调用 tts.SynthesizeStream() → 发送 tts_audio
  5. 流结束 → 发送 llm_done
  6. TTS 失败静默跳过STT/LLM 失败发送对应 error 消息

并发控制

  • 每个 ProcessQuery 调用在独立 goroutine 中运行
  • context.WithTimeout 确保 10 秒总超时
  • interrupt 消息触发 cancel()LLM/TTS 流式全部中断
  • 同一 session 内同时只允许一个活跃请求,新请求自动取消上一个

错误处理与降级

故障点 处理策略 客户端表现
STT 失败 发送 STT_ERROR,终止本次请求 回退到纯文本模式
LLM 超时(>10s 发送 LLM_TIMEOUT,取消 TTS 提示用户重试
LLM 部分输出后失败 已推送的 llm_chunk 保留,发送 error 通知中断 显示已收到的部分文字
TTS 失败 静默跳过,llm_done 正常发送 只有文字回复,无语音
interrupt 打断 cancel context清空所有流 前端清空播放队列

五、Session Manager

WebSocket Handler 和 AI Orchestrator 之间的会话管理层。负责维护会话生命周期、对话上下文和配置状态。

数据结构

Memory 存储(默认):

MemoryManager
├── sessions: map[string]*sessionEntry
│   ├── session     models.Session
│   ├── history     []models.Message
│   ├── activeReqID string
│   └── lastActive  time.Time
├── msgRepo:  MessageRepository  (Write-Through, 可选)
└── sessRepo: SessionRepository  (Write-Through, 可选)

Redis 存储(可选):

session:{id}:meta    → Hash   (会话元数据)
session:{id}:history → List   (对话历史)
user:{id}:sessions   → Set    (用户会话索引)

TTL 策略

场景 TTL 说明
创建时 30 分钟 EXPIRE 设置
每次收到消息 重置 30 分钟 EXPIRE 刷新
WebSocket 断开 不主动删 等自然过期,支持重连恢复
超过 30 分钟无活动 自动过期 自动清理
显式销毁REST API 立即删除

接口定义

type Manager interface {
    // Create 创建新会话,关联 user_id返回 session ID。
    Create(ctx context.Context, userID string, config models.SessionConfig) (string, error)

    // Get 获取会话(含 config。不存在返回 ErrSessionNotFound。
    Get(ctx context.Context, sessionID string) (*models.Session, error)

    // UpdateConfig 更新会话配置config 消息触发)。
    UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error

    // UpdateTitle 更新对话标题。
    UpdateTitle(ctx context.Context, sessionID string, title string) error

    // ListByUser 获取用户的对话列表(分页)。
    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)

    // AppendMessage 追加一条对话消息,同时刷新 TTL。首条 user 消息自动更新标题。
    AppendMessage(ctx context.Context, sessionID string, msg models.Message) error

    // SetActiveRequest 标记当前正在处理的请求 IDinterrupt 用)。
    SetActiveRequest(ctx context.Context, sessionID string, requestID string) error

    // GetActiveRequestID 获取当前活跃请求 ID。
    GetActiveRequestID(ctx context.Context, sessionID string) (string, error)

    // ClearActiveRequest 清除活跃请求标记(请求完成或中断后)。
    ClearActiveRequest(ctx context.Context, sessionID string) error

    // Touch 刷新 TTL心跳时调用
    Touch(ctx context.Context, sessionID string) error

    // Destroy 显式销毁会话REST API DELETE
    Destroy(ctx context.Context, sessionID string) error

    // ActiveCount 返回当前活跃会话数(健康检查用)。
    ActiveCount() int
}

// 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"`
}

Write-Through 机制

MemoryManager 支持注入 MessageRepositorySessionRepository,实现写穿持久化:

opts := []session.Option{}
if msgRepo != nil {
    opts = append(opts, session.WithMessageRepository(msgRepo))
}
if sessRepo != nil {
    opts = append(opts, session.WithSessionRepository(sessRepo))
}
sessionMgr = session.NewMemoryManager(30*time.Minute, 20, opts...)
  • Create 时同时写入 PG sessions 表
  • AppendMessage 时同时写入 PG messages 表
  • Get 时如果内存中不存在,尝试从 PG 恢复

自动标题生成

首条 user 消息时,如果 title 仍为 "新对话",自动更新为消息内容前 20 个字符。


六、存储层接口

通过 Repository 接口隔离存储层,内存和 PostgreSQL 均已实现。

UserRepository

type UserRepository interface {
    Create(ctx context.Context, username, passwordHash string) (string, error)
    FindByUsername(ctx context.Context, username string) (*User, error)
    FindByID(ctx context.Context, id string) (*User, error)
    SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
    FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
    DeleteRefreshToken(ctx context.Context, tokenHash string) error
    DeleteUserRefreshTokens(ctx context.Context, userID string) error
}

MessageRepository

type MessageRepository interface {
    SaveMessage(ctx context.Context, sessionID string, msg models.Message, tokensUsed int) error
    GetMessages(ctx context.Context, sessionID string, limit int, beforeID int64) ([]StoredMessage, error)
    GetLastMessage(ctx context.Context, sessionID string) (*StoredMessage, error)
    GetMessageCount(ctx context.Context, sessionID string) (int, error)
    GetSessionMessageStats(ctx context.Context, sessionIDs []string) (map[string]int, error)
}

SessionRepository

type SessionRepository interface {
    Save(ctx context.Context, session models.Session) error
    FindByID(ctx context.Context, id string) (*models.Session, error)
    FindByUser(ctx context.Context, userID string, page, size int) ([]models.Session, int, error)
    UpdateTitle(ctx context.Context, id string, title string) error
    UpdateConfig(ctx context.Context, id string, config models.SessionConfig) error
    Touch(ctx context.Context, id string) error
    Delete(ctx context.Context, id string) error
}

依赖注入

if cfg.Storage.Driver == "postgres" {
    pool, _ := store.NewPostgresPool(ctx, cfg.Storage.DSN)
    userRepo = store.NewPgUserRepository(pool)
    msgRepo = store.NewPgMessageRepository(pool)
    sessRepo = store.NewPgSessionRepository(pool)
    sessionMgr = session.NewMemoryManager(30*time.Minute, 20,
        session.WithMessageRepository(msgRepo),
        session.WithSessionRepository(sessRepo),
    )
} else {
    userRepo = store.NewMemUserRepository()
    sessionMgr = session.NewMemoryManager(30*time.Minute, 20)
}

七、配置管理

使用 Viper 加载配置,支持 YAML 文件 + 环境变量覆盖。环境变量优先级高于配置文件

配置文件位置

backend/config.yaml        # 默认加载
backend/config.dev.yaml    # 开发环境
backend/config.prod.yaml   # 生产环境

加载顺序:config.yamlconfig.{APP_ENV}.yaml → 环境变量(前缀 CAMTALK_)。

配置结构体

type Config struct {
    App      AppConfig      `mapstructure:"app"`
    Server   ServerConfig   `mapstructure:"server"`
    Session  SessionConfig  `mapstructure:"session"`
    Redis    RedisConfig    `mapstructure:"redis"`
    AI       AIConfig       `mapstructure:"ai"`
    Storage  StorageConfig  `mapstructure:"storage"`
    Auth     AuthConfig     `mapstructure:"auth"`
    Log      LogConfig      `mapstructure:"log"`
}

type AppConfig struct {
    Env     string `mapstructure:"env"`     // "dev" | "prod",默认 "dev"
    Version string `mapstructure:"version"` // 由编译时注入
}

type ServerConfig struct {
    Host              string   `mapstructure:"host"`               // 默认 "0.0.0.0"
    Port              int      `mapstructure:"port"`               // 默认 8080
    ReadTimeout       int      `mapstructure:"read_timeout"`       // 秒,默认 30
    WriteTimeout      int      `mapstructure:"write_timeout"`      // 秒,默认 30
    HeartbeatInterval int      `mapstructure:"heartbeat_interval"` // 秒,默认 30
    HeartbeatTimeout  int      `mapstructure:"heartbeat_timeout"`  // 秒,默认 60
    ShutdownTimeout   int      `mapstructure:"shutdown_timeout"`   // 秒,默认 10
    AllowedOrigins    []string `mapstructure:"allowed_origins"`    // 空表示允许所有
}

type SessionConfig struct {
    TTL        int `mapstructure:"ttl"`         // 分钟,默认 30
    MaxHistory int `mapstructure:"max_history"`  // 条数,默认 20
}

type RedisConfig struct {
    Addr     string `mapstructure:"addr"`      // "localhost:6379"
    Password string `mapstructure:"password"`
    DB       int    `mapstructure:"db"`        // 默认 0
}

type AIConfig struct {
    STT STTConfig `mapstructure:"stt"`
    LLM LLMConfig `mapstructure:"llm"`
    TTS TTSConfig `mapstructure:"tts"`
}

type STTConfig struct {
    Provider          string `mapstructure:"provider"`             // "deepgram" | "mimo" | "xiaomi"
    APIKey            string `mapstructure:"api_key"`
    Model             string `mapstructure:"model"`                // 默认 "nova-2"
    Endpoint          string `mapstructure:"endpoint"`
    Timeout           int    `mapstructure:"timeout"`              // 秒,默认 5
    HTTPClientTimeout int    `mapstructure:"http_client_timeout"`  // 秒,默认 30
}

type LLMConfig struct {
    Provider          string `mapstructure:"provider"`             // "openai"
    APIKey            string `mapstructure:"api_key"`
    Model             string `mapstructure:"model"`                // 默认 "gpt-4o"
    Endpoint          string `mapstructure:"endpoint"`
    Timeout           int    `mapstructure:"timeout"`              // 秒,默认 10
    HTTPClientTimeout int    `mapstructure:"http_client_timeout"`  // 秒,默认 60
}

type TTSConfig struct {
    Provider          string  `mapstructure:"provider"`             // "openai" | "mimo" | "xiaomi"
    APIKey            string  `mapstructure:"api_key"`
    Model             string  `mapstructure:"model"`                // 默认 "tts-1"
    Voice             string  `mapstructure:"voice"`                // 默认 "mimo_default"
    Speed             float64 `mapstructure:"speed"`                // 默认 1.0
    Endpoint          string  `mapstructure:"endpoint"`
    Timeout           int     `mapstructure:"timeout"`              // 秒,默认 5
    HTTPClientTimeout int     `mapstructure:"http_client_timeout"`  // 秒,默认 30
    OutputFormat      string  `mapstructure:"output_format"`        // 默认 "mp3"
    SampleRate        int     `mapstructure:"sample_rate"`          // 默认 24000
}

type StorageConfig struct {
    Driver string `mapstructure:"driver"` // "memory" | "postgres"
    DSN    string `mapstructure:"dsn"`
}

type AuthConfig struct {
    JWTSecret  string `mapstructure:"jwt_secret"`   // 必须通过 CAMTALK_AUTH_JWT_SECRET 设置
    AccessTTL  int    `mapstructure:"access_ttl"`   // 分钟,默认 15
    RefreshTTL int    `mapstructure:"refresh_ttl"`  // 分钟,默认 100807 天)
}

type LogConfig struct {
    Level  string `mapstructure:"level"`  // "debug" | "info" | "warn" | "error",默认 "info"
    Format string `mapstructure:"format"` // "json" | "console"
}

配置文件示例

app:
  env: dev

server:
  host: "0.0.0.0"
  port: 8080
  read_timeout: 30
  write_timeout: 30
  heartbeat_interval: 30
  heartbeat_timeout: 60
  shutdown_timeout: 10

session:
  ttl: 30
  max_history: 20

redis:
  addr: "localhost:6379"
  password: ""
  db: 0

ai:
  stt:
    provider: deepgram
    model: nova-2
    endpoint: "wss://api.deepgram.com/v1/listen"
    timeout: 5
    http_client_timeout: 30
  llm:
    provider: openai
    model: gpt-4o
    endpoint: "https://api.openai.com/v1"
    timeout: 10
    http_client_timeout: 60
  tts:
    provider: openai
    model: tts-1
    voice: mimo_default
    speed: 1.0
    endpoint: "https://api.openai.com/v1"
    timeout: 5
    http_client_timeout: 30
    output_format: mp3
    sample_rate: 24000

storage:
  driver: memory

auth:
  access_ttl: 15
  refresh_ttl: 10080

log:
  level: info
  format: console

环境变量覆盖规则

前缀 CAMTALK_ + 路径大写用 _ 连接:

配置项 环境变量
server.port CAMTALK_SERVER_PORT
redis.addr CAMTALK_REDIS_ADDR
ai.stt.api_key CAMTALK_AI_STT_API_KEY
ai.llm.api_key CAMTALK_AI_LLM_API_KEY
ai.tts.api_key CAMTALK_AI_TTS_API_KEY
storage.driver CAMTALK_STORAGE_DRIVER
storage.dsn CAMTALK_STORAGE_DSN
auth.jwt_secret CAMTALK_AUTH_JWT_SECRET
log.level CAMTALK_LOG_LEVEL

API Key 和密码只通过环境变量注入,不写入配置文件,避免泄露到版本控制。

启动命令

# 开发环境
CAMTALK_AI_LLM_API_KEY=sk-xxx \
CAMTALK_AI_STT_API_KEY=xxx \
go run ./cmd/server

# 生产环境
CAMTALK_APP_ENV=prod \
CAMTALK_STORAGE_DRIVER=postgres \
CAMTALK_STORAGE_DSN="postgres://user:pass@db:5432/camtalk?sslmode=disable" \
CAMTALK_AUTH_JWT_SECRET="$(openssl rand -hex 32)" \
./bin/camtalk

八、数据模型

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 SessionConfig struct {
    TTSEnabled  bool   `json:"tts_enabled"`
    DetailLevel string `json:"detail_level"` // "low" | "high"
    Language    string `json:"language"`
    Scenario    string `json:"scenario"`     // "free_chat" | "interviewer" | "english_teacher" | "debate" | "interpreter"
}

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 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 前端模型

interface SessionConfig {
  ttsEnabled: boolean;
  detailLevel: "low" | "high";
  language: string;
  scenario: string;
}

interface ChatMessage {
  role: "user" | "assistant";
  content: string;
  imageUrl?: string;
  timestamp: number;
  tokensUsed?: number;
}

interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

interface User {
  id: string;
  username: string;
  created_at: string;
}

// WebSocket 消息联合类型
type ServerMessage =
  | ConnectedMessage
  | STTResultMessage
  | LLMChunkMessage
  | LLMDoneMessage
  | TTSAudioMessage
  | ErrorMessage
  | PongMessage;

type ClientMessage =
  | QueryMessage
  | ConfigMessage
  | InterruptMessage
  | PingMessage;

九、错误码

错误码 HTTP 状态码 含义 客户端处理建议
INVALID_MESSAGE 消息格式不合法WS 检查 JSON 结构,不重试
SESSION_NOT_FOUND 404 会话/对话不存在或已过期 重新建立连接或刷新列表
RATE_LIMITED 429 请求频率超限 延迟后重试,提示用户稍等
IMAGE_TOO_LARGE 图像超过 4MB 限制WS 降低分辨率或压缩质量
AUDIO_TOO_SHORT 音频片段 < 250msWS 忽略,等待下次语音输入
LLM_TIMEOUT LLM 推理超时 >10sWS 提示用户重试
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 请求参数校验失败 检查字段规则后重试

十、连接管理

心跳机制:客户端每 30 秒发送应用层 {type: "ping"} 消息,服务端回复 {type: "pong"} 并刷新心跳计时器。超过 60 秒无 ping,服务端判定连接断开并清理会话资源。

重连策略(指数退避 + 抖动):

function reconnect(attempt: number) {
  const delay = Math.min(1000 * Math.pow(2, attempt), 30000); // 最大 30s
  const jitter = Math.random() * 1000;
  setTimeout(() => connect(), delay + jitter);
}
// attempt: 0 → 1s, 1 → 2s, 2 → 4s, 3 → 8s, ... 最大 30s

跨域处理

采用 Nginx 同源反代方案,前后端统一到同一域名,浏览器层面不存在跨域问题。

开发环境:前端 WebSocket 地址基于 window.location.host 动态构建,通过 Vite server.proxy 转发到后端 http://localhost:8080