19 — API 与后端交互
本文档讲解前端如何与后端通信。
涉及:HTTP 请求封装、错误处理、Vite 代理、接口定义、SSE 流式 URL。
1. 通信方式概览
| 方式 | 用途 | 文件 |
|---|
| HTTP JSON | 会话管理、文件上传 | api/client.ts |
| SSE | AI 回复流式推送 | api/stream.ts + useSseStream.ts |
| Vite 代理 | 解决开发时跨域 | vite.config.ts |
2. HTTP 请求封装
文件:src/api/client.ts
2.1 API 基础路径
1
| const API_BASE = import.meta.env.VITE_API_BASE || '/api';
|
- 开发时通过 Vite 代理,
/api 自动转发到后端 - 生产环境通过环境变量配置:
VITE_API_BASE=https://api.example.com/api
2.2 三个封装函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| export async function httpGet<T>(path: string): Promise<T> { const res = await fetch(`${API_BASE}${path}`, { method: 'GET', headers: { Accept: 'application/json' }, }); return parseOrThrow<T>(res); }
export async function httpDelete<T>(path: string): Promise<T> { const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE' }); return parseOrThrow<T>(res); }
export async function httpPostForm<T>(path: string, form: FormData): Promise<T> { const res = await fetch(`${API_BASE}${path}`, { method: 'POST', body: form, }); return parseOrThrow<T>(res); }
|
泛型 <T> 让调用方指定返回类型:
1 2 3 4 5
| const data = await httpGet('/session/list');
const data = await httpGet<{ records: SessionListVO[] }>('/session/list');
|
2.3 响应解析(BaseResult)
后端返回的统一格式:
1 2 3 4 5
| interface BaseResult<T> { code: number; message: string; data: T; }
|
parseOrThrow 解析这个格式:
1 2 3 4 5 6 7 8 9 10 11 12 13
| async function parseOrThrow<T>(res: Response): Promise<T> { if (!res.ok) { const body = await res.json().catch(() => null); throw new ApiError(`HTTP ${res.status}: ${body?.message ?? ''}`, res.status); } const body = (await res.json()) as BaseResult<T>; if (body.code !== 200) { throw new ApiError(body.message, body.code); } return body.data; }
|
2.4 错误类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| export class ApiError extends Error { constructor(message: string, public code?: number) { super(message); this.name = 'ApiError'; } }
try { const data = await httpGet(...); } catch (e) { if (e instanceof ApiError) { console.log('API 错误:', e.message, e.code); } else { console.log('其他错误:', e); } }
|
3. 各模块的 API
3.1 会话 API
文件:src/api/session.ts
1 2 3 4 5 6 7 8
| import { httpGet, httpDelete } from './client'; import type { SessionListVO, SessionDetailVO } from '@/types/api';
export const sessionApi = { list: () => httpGet<{ records: SessionListVO[] }>('/session/list'), detail: (id: string) => httpGet<SessionDetailVO>(`/session/${id}`), remove: (id: string) => httpDelete<void>(`/session/${id}`), };
|
3.2 文件 API
文件:src/api/file.ts
1 2 3 4 5 6 7 8 9 10 11
| import { httpGet, httpPostForm } from './client'; import type { FileInfoVO } from '@/types/api';
export const fileApi = { list: () => httpGet<unknown>('/file/list'), upload: (file: File) => { const form = new FormData(); form.append('file', file); return httpPostForm<FileInfoVO>('/file/upload', form); }, };
|
3.3 SSE URL 构建
文件:src/api/stream.ts
SSE 不通过 httpGet 调用(要自己处理流),只构建 URL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| export function getStreamUrl( agentType: string, hasFile: boolean, message: string, conversationId: string, fileId?: string | null, ): string { const params = new URLSearchParams({ agentType, conversationId }); if (message) params.set('message', message); if (fileId) params.set('fileId', fileId); return `${API_BASE}/chat/stream?${params}`; }
export function getStopUrl(conversationId: string): string { return `${API_BASE}/chat/stop/${conversationId}`; }
|
使用(在 chatStore.ts):
1 2 3 4 5 6 7 8
| const url = getStreamUrl( state.selectedAgent, hasFile, message, chat.id, fileIdToSend, );
|
4. 类型定义
文件:src/types/api.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| export interface BaseResult<T> { code: number; message: string; data: T; }
export interface SessionListVO { conversationId: string; agentType: string; question: string | null; answer: string | null; messageCount: number; createTime: string | null; updateTime: string | null; fileid: string | null; }
export interface MessageVO { id: number; question: string | null; answer: string | null; thinking: string | null; tools: string | null; reference: string | null; recommend: string | null; fileid: string | null; createTime: string | null; }
export interface SessionDetailVO { conversationId: string; agentType: string; fileid: string | null; messages: MessageVO[]; }
export interface FileInfoVO { fileId: string; fileName: string; [key: string]: unknown; }
|
约定:
VO = Value Object(值对象)- 后端字段命名用驼峰
- 字段可能是
null(用 T | null)
5. 数据转换(前端领域对象)
后端返回的数据和前端用的不完全一致,Store 中有转换函数:
位置:src/store/chatStore.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| function mapSessionListItem(item: SessionListVO): Chat { return { id: item.conversationId, title: item.question ? shortTitle(item.question) : '新对话', messages: [], isNew: false, agentType: item.agentType, fileid: item.fileid, }; }
function mapSessionDetail(detail: SessionDetailVO): ChatMessage[] { const out: ChatMessage[] = []; for (const msg of detail.messages ?? []) { if (msg.question) { out.push({ id: `user_${msg.id}`, role: 'user', content: msg.question, file: !!msg.fileid, fileName: msg.fileid ? '已上传文件' : null, timestamp: msg.createTime ? new Date(msg.createTime).getTime() : Date.now(), }); } if (msg.answer || msg.thinking) { out.push({ id: `assistant_${msg.id}`, role: 'assistant', content: msg.answer || '', thinking: msg.thinking ? [msg.thinking] : [], timeline: msg.thinking ? [{ type: 'thinking', content: msg.thinking }] : [], reference: processReferences(msg.reference), recommend: processRecommendations(msg.recommend), showTimeline: false, showReference: false, timestamp: msg.createTime ? new Date(msg.createTime).getTime() : Date.now(), }); } } return out; }
|
为什么要转换:
- 字段重命名(
conversationId → id) - 简化结构(
tools JSON → timeline[]) - 提供前端默认值
6. Vite 代理
文件:vite.config.ts
1 2 3 4 5 6 7 8 9 10 11
| export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, });
|
为什么需要:
1 2 3 4 5 6 7 8 9
| 开发时: - 前端在 http://localhost:5173 - 后端在 http://localhost:8080 - 浏览器同源策略禁止跨域
解决:Vite 在中间做代理 - 前端请求 http://localhost:5173/api/xxx - Vite 看到 /api 开头,转发到 http://localhost:8080/api/xxx - 浏览器以为是同源(都是 5173)
|
生产环境怎么解决?
需要后端/Nginx 处理:
Nginx 配置示例(含 SSE 特殊处理):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| server { listen 80; server_name example.com;
root /var/www/dodo-agent/dist; index index.html; location / { try_files $uri $uri/ /index.html; }
location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /api/chat/stream { proxy_pass http://localhost:8080; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ''; proxy_http_version 1.1; }
location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; } }
|
7. 后端接口规范
项目前端假设后端接口格式如下:
7.1 列表会话
1 2
| GET /api/session/list → { code: 200, message: "ok", data: { records: SessionListVO[], total: 100 } }
|
7.2 会话详情
1 2
| GET /api/session/{conversationId} → { code: 200, data: SessionDetailVO }
|
7.3 删除会话
1 2
| DELETE /api/session/{conversationId} → { code: 200, data: null }
|
7.4 文件上传
1 2 3 4 5
| POST /api/file/upload Content-Type: multipart/form-data file: <binary>
→ { code: 200, data: { fileId: "xxx", fileName: "test.pdf" } }
|
7.5 SSE 流
1 2 3 4 5 6 7 8 9 10 11
| GET /api/chat/stream?agentType=chat&conversationId=xxx&message=hello[&fileId=xxx] Content-Type: text/event-stream
data: {"type":"thinking","content":"让我想想..."}\n \n data: {"type":"text","content":"Spring"}\n \n data: {"type":"complete"}\n \n data: [DONE]\n \n
|
7.6 停止生成
1 2
| GET /api/chat/stop/{conversationId} → { code: 200, data: null }
|
8. 调试技巧
- 打开 DevTools → Network 标签
- 过滤
/api - 查看每个请求:
- Headers — 请求头、状态码
- Payload — 请求体(POST)
- Response — 响应内容
- Timing — 耗时
8.2 调试 SSE(核心)
- DevTools → Network → 过滤
stream - 发消息 → 点击
chat/stream 请求 - 切换到 Response 标签
- 实时看到推送的数据(不会一次性显示)
8.3 模拟慢网络
Network → 选请求 → 右键 → “Replay XHR” / 或用 throttling 模拟慢网络
8.4 直接调 API 测试
1 2
| fetch('/api/session/list').then(r => r.json()).then(console.log)
|
9. 常见错误
| 错误 | 原因 | 解决 |
|---|
Failed to fetch | 后端没启动 / 跨域 / 端口错 | 启动后端、检查代理配置 |
| HTTP 404 | 接口路径错 | 检查后端路由 |
| HTTP 500 | 后端代码错误 | 查看后端日志 |
code !== 200 | 业务错误(如权限不足) | 看 message 字段 |
| SSE 立即关闭 | 代理缓冲 / 后端不支持 | 关闭 Nginx buffering、确认后端用 SSE |
| 超时 | SSE 连接超时 | 设置 proxy_read_timeout 足够长 |
10. 实战:怎么改接口
场景 1:后端改了接口路径
1 2 3 4
|
list: () => httpGet<{ records: SessionListVO[] }>('/v2/sessions'),
|
场景 2:后端加了新字段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
interface SessionListVO { tag?: string; }
function mapSessionListItem(item: SessionListVO): Chat { return { tag: item.tag, }; }
|
场景 3:加新接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| export interface TagVO { id: string; name: string; }
import { httpGet } from './client'; import type { TagVO } from '@/types/api';
export const tagApi = { list: () => httpGet<TagVO[]>('/tags'), };
loadTags: async () => { const tags = await tagApi.list(); set({ tags }); },
const tags = useChatStore(s => s.tags);
|
11. 一段话总结
API 层 = 三个函数(httpGet/httpDelete/httpPostForm)+ URL 构建。
类型 = 后端响应格式定义在 types/api.ts。
转换 = Store 中把后端 VO 转成前端领域对象。
代理 = 开发时 Vite 代理解决跨域;生产时用 Nginx(含 SSE 配置)。
改接口 = 类型 + API + Store 三处同步改。
接下来
最后一篇是 20-开发者指南.md — 安装、启动、调试、部署完整流程。