19 — API 与后端交互

本文档讲解前端如何与后端通信。
涉及:HTTP 请求封装、错误处理、Vite 代理、接口定义、SSE 流式 URL。


1. 通信方式概览

方式用途文件
HTTP JSON会话管理、文件上传api/client.ts
SSEAI 回复流式推送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
// GET 请求
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);
}

// DELETE 请求
export async function httpDelete<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { method: 'DELETE' });
return parseOrThrow<T>(res);
}

// POST FormData(文件上传)
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
// 不指定:TS 不知道返回什么
const data = await httpGet('/session/list');

// 指定:data 是 { records: SessionListVO[] } 类型
const data = await httpGet<{ records: SessionListVO[] }>('/session/list');

2.3 响应解析(BaseResult)

后端返回的统一格式:

1
2
3
4
5
interface BaseResult<T> {
code: number; // 业务状态码(200 成功)
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) {
// HTTP 错误(4xx、5xx)
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; // 只返回 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,
);
// url: /api/chat/stream?agentType=chat&conversationId=xxx&message=hello

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; // 工具调用 JSON
reference: string | null; // 参考来源 JSON
recommend: string | null; // 推荐问题 JSON
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
// 会话列表 → Chat[]
function mapSessionListItem(item: SessionListVO): Chat {
return {
id: item.conversationId,
title: item.question ? shortTitle(item.question) : '新对话',
messages: [], // 列表不加载消息,点击后再加载
isNew: false,
agentType: item.agentType,
fileid: item.fileid,
};
}

// 会话详情 → ChatMessage[]
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;
}

为什么要转换

  • 字段重命名(conversationIdid
  • 简化结构(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; # SPA 路由
}

# 后端 API(普通请求)
location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}

# SSE 必需特殊配置
location /api/chat/stream {
proxy_pass http://localhost:8080;
proxy_buffering off; # 关闭缓冲(SSE 必须)
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. 调试技巧

8.1 浏览器 DevTools

  1. 打开 DevTools → Network 标签
  2. 过滤 /api
  3. 查看每个请求:
    • Headers — 请求头、状态码
    • Payload — 请求体(POST)
    • Response — 响应内容
    • Timing — 耗时

8.2 调试 SSE(核心)

  1. DevTools → Network → 过滤 stream
  2. 发消息 → 点击 chat/stream 请求
  3. 切换到 Response 标签
  4. 实时看到推送的数据(不会一次性显示)

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
// 假设后端把 /session/list 改成 /v2/sessions

// 1. 改 session.ts
list: () => httpGet<{ records: SessionListVO[] }>('/v2/sessions'),

场景 2:后端加了新字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设后端 SessionListVO 加了 tag 字段

// 1. 改类型
interface SessionListVO {
// ... 现有字段
tag?: string; // ← 新加,可选
}

// 2. 改转换函数
function mapSessionListItem(item: SessionListVO): Chat {
return {
// ... 现有
tag: item.tag, // ← 同步到前端 Chat
};
}

场景 3:加新接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 在 types/api.ts 加类型
export interface TagVO {
id: string;
name: string;
}

// 2. 在 api/tag.ts 加 API
import { httpGet } from './client';
import type { TagVO } from '@/types/api';

export const tagApi = {
list: () => httpGet<TagVO[]>('/tags'),
};

// 3. 在 Store 加 action
loadTags: async () => {
const tags = await tagApi.list();
set({ tags });
},

// 4. 在组件用
const tags = useChatStore(s => s.tags);

11. 一段话总结

API 层 = 三个函数(httpGet/httpDelete/httpPostForm)+ URL 构建。
类型 = 后端响应格式定义在 types/api.ts
转换 = Store 中把后端 VO 转成前端领域对象。
代理 = 开发时 Vite 代理解决跨域;生产时用 Nginx(含 SSE 配置)。
改接口 = 类型 + API + Store 三处同步改。


接下来

最后一篇是 20-开发者指南.md — 安装、启动、调试、部署完整流程。