MkSaaS 前端页面调用后端 API,核心是先把请求地址、鉴权头和租户信息对齐后端定义,再封装一个统一的请求函数,避免每个页面重复写。流式输出要单独处理,不能按普通 JSON 解析。如果遇到 401,要设计刷新重放逻辑,并在浏览器 Network 面板里核对实际发出的请求。
适用场景:使用 MkSaaS 生成的后端接口,前端需要对接并处理鉴权、租户和流式响应。操作动作:从后端路由定义确认前缀和鉴权字段,封装 fetch 请求自动注入 token 和租户 ID,用 ReadableStream 解析 SSE,并在 401 时刷新重放。验证方式:在 Network 面板检查 URL、请求头、状态码和响应体。风险边界:token 刷新和租户字段需与后端实现保持一致,流式解析要考虑分片和中断。
先确认 MkSaaS 生成的 API 路由前缀与鉴权方式
不要靠猜。打开后端项目,找路由注册的地方。FastAPI 通常在 include_router 里写 prefix="/api/v1";Spring Boot 在 application.yml 里配置 servlet.context-path。如果后端提供 Swagger 文档,直接看接口的完整路径,前缀通常会在页面顶部标出。
鉴权也一样,去后端看拦截器或装饰器里从哪个头取 token。常见的写法是 Authorization: Bearer <token>,但也有些内部系统用 X-Token 或 X-Api-Key。租户信息同理,MkSaaS 多租户场景下一般放在自定义头里,比如 X-Tenant-ID。确认后再写前端,否则请求发到错误路径或错误字段,后端根本不认。
封装统一的请求实例,注入 token 和租户 ID
每次请求都手动带 token 很容易漏,而且后面改字段要满项目找。写一个 apiFetch 函数,自动从 localStorage 读取 token 和租户 ID,并附加到请求头上。
const API_BASE = '/api/v1';
function apiFetch(path, options = {}) {
const token = localStorage.getItem('mk_token');
const tenantId = localStorage.getItem('mk_tenant_id');
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (tenantId) {
headers['X-Tenant-ID'] = tenantId;
}
return fetch(`${API_BASE}${path}`, {
...options,
headers,
});
}如果后端要求的头不是这两个名字,直接改函数里的赋值部分。这样所有请求都走同一个入口,后续加 trace 头、改超时都方便。
针对流式响应使用特定的 fetch 读取方式
对话式 AI 接口通常返回 SSE 流,不能直接 response.json()。需要从 response.body 拿到可读流,用 getReader() 读 chunk,再按行切分。注意中文等字符可能被拆到两个 chunk 里,必须用 buffer 拼接。
async function streamSSE(url, onMessage, onError) {
const token = localStorage.getItem('mk_token');
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一段可能不完整,留到下一轮
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.slice(5).trim();
if (data === '[DONE]') return;
onMessage(data);
}
}
}
}这段代码把每次读取到的数据追加到 buffer,按换行分割后只处理完整行,剩余残片留在 buffer 等下次拼上。这样能避免中文被截断产生乱码。调用时传回调函数,比如 onMessage 里做 setState 或直接更新界面。
处理 401 时刷新令牌并重放请求
token 过期后,前端如果直接抛错,用户得重新登录。更顺滑的做法是拦截 401,刷新 token 后重放原请求。但注意防止递归死循环——刷新接口本身也可能 401。
let refreshPromise = null;
async function apiFetchWithRetry(path, options = {}) {
const response = await apiFetch(path, options);
if (response.status === 401) {
// 刷新接口自身失败,直接跳登录
if (path === '/auth/refresh') {
window.location.href = '/login';
throw new Error('Unauthorized');
}
// 并发请求只触发一次刷新
if (!refreshPromise) {
refreshPromise = refreshToken().finally(() => {
refreshPromise = null;
});
}
await refreshPromise;
return apiFetch(path, options); // 重放一次
}
return response;
}这里用 refreshPromise 保证多个请求同时遇到 401 时只刷新一次,其他请求等待同一个 Promise。重放只做一次,如果再次 401 就交给上层处理,避免死循环。
在浏览器 network 面板验证请求头与响应状态
写完代码,用浏览器自带开发者工具验证。按 F12 打开 Network,刷新页面或触发操作,找到对应的请求,检查这几项:
- Request URL:是否带着正确的 API 前缀,比如
/api/v1/xxx。 - Authorization:请求头里是不是
Bearer <token>,token 值是否在当前的有效期内。 - X-Tenant-ID:租户 ID 是否已经带上,值是否与当前用户所属租户匹配。
- Status Code:是否 200/201。如果看到 401/403/404/500,需要针对排查。
- Response:响应体大小和内容是否合理,流式接口应该能看到持续的 chunk 输出。
常见异常排查:401 先从 Network 里看发的头是否完整,再去后端确认 token 解析逻辑;403 多半是租户 ID 没传或没权限;404 通常是 API 前缀或路径写错;500 需要看后端日志,前端能确认的是请求体格式是否和接口文档一致。