- 新建 01-架构设计.md:合并项目概述+系统架构+持久化设计,含 Mermaid 架构图、模块图、时序图、ER 图、部署图 - 新建 02-接口文档.md:合并接口文档+持久化 API+用户模块 API,统一格式去重 - 重编号 03~09,去掉状态标注,规划中功能标记为待实现 - 删除 PLAN_BACKEND.md、PLAN_USER_MODULE.md 及冗余文档
38 KiB
接口文档
概述
前后端通信接口定义。以 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/mp3(MP3) |
浏览器 <audio> 原生支持 |
| 采样率 | 24kHz | OpenAI TTS 默认 |
| 声道 | 单声道 | 语音不需要立体声 |
| 传输 | Base64 编码的 MP3 片段 | 每个 tts_audio 消息携带一个句子的音频 |
| 切片粒度 | 按句子切分 | LLM 输出中按 。!?\n 等标点切分,每个句子独立合成 |
流式播放时序:tts_audio 消息按句子顺序到达,前端应按序排队播放,不要等全部到齐再播。
前端播放实现要点:
- 排队播放:收到
is_last: true时,将该句子的音频片段拼接为 Blob URL 并加入播放队列。第一句到达即开始播放,后续句子在onended回调中自动衔接。 - 错误容错:单个句子播放失败时跳过,继续播放队列中下一个,不中断整个回复。
- 打断清理:收到
interrupt消息或用户触发打断时,清空播放队列并释放所有 Blob URL。 - 类型锁定:
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; // JWT,15 分钟有效
refresh_token: string; // JWT,7 天有效
}
错误响应:
| 状态码 | 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 流式输出逐 token,TTS 需要完整句子才能合成。解法:句子切分器 + 管道并行。
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 实现流程:
- Base64 解码音频/图片
- 调用
stt.Recognize()→ 发送stt_result - 调用
llm.ChatStream()获取流式输出,goroutine 消费 token → 发送llm_chunk+ 句子切分 - 另一 goroutine 从句子 channel 读取 → 调用
tts.SynthesizeStream()→ 发送tts_audio - 流结束 → 发送
llm_done - 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 标记当前正在处理的请求 ID(interrupt 用)。
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 支持注入 MessageRepository 和 SessionRepository,实现写穿持久化:
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.yaml → config.{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"` // 分钟,默认 10080(7 天)
}
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 |
— | 音频片段 < 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 | 请求参数校验失败 | 检查字段规则后重试 |
十、连接管理
心跳机制:客户端每 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。