fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置
- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复) - 修复IoT子会话工具调用链路日志缺失 - 新增最终审查子会话(review_provider) 支持消息格式解析拆分 - 实现历史消息持久化(后端存储+前端分页加载) - 前端新增动作消息(ActionMessage)类型和渲染 - 优化对话链路速度(非阻塞子会话+快速问候通道) - JWT密钥环境变量化(无默认值启动panic) - Token自动刷新机制(401拦截器+refresh接口) - WebSocket指数退避重连(jitter+最大10次) - localStorage清理一致性(cyrene_前缀+版本检查) - IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
@@ -5,6 +5,8 @@ export {
|
||||
refreshToken,
|
||||
setToken,
|
||||
getToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
} from './client';
|
||||
|
||||
+200
-55
@@ -1,9 +1,154 @@
|
||||
// HTTP 客户端封装
|
||||
// HTTP 客户端封装 — 带 Token 自动刷新拦截器
|
||||
|
||||
import type { AuthResponse } from '@/types/session';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
|
||||
|
||||
/** localStorage key 前缀 */
|
||||
const LS_TOKEN_KEY = 'token';
|
||||
const LS_REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
const LS_USER_ID_KEY = 'user_id';
|
||||
|
||||
// ========== Token 刷新队列(避免多个并发请求同时刷新 token) ==========
|
||||
|
||||
let isRefreshing = false;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
/**
|
||||
* 等待中的请求队列。当 token 刷新完成后,用新 token 重放这些请求。
|
||||
*/
|
||||
type QueuedRequest = {
|
||||
resolve: (token: string | null) => void;
|
||||
reject: (err: unknown) => void;
|
||||
};
|
||||
let refreshQueue: QueuedRequest[] = [];
|
||||
|
||||
function onRefreshDone(newToken: string | null) {
|
||||
for (const q of refreshQueue) {
|
||||
q.resolve(newToken);
|
||||
}
|
||||
refreshQueue = [];
|
||||
}
|
||||
|
||||
function onRefreshFailed(err: unknown) {
|
||||
for (const q of refreshQueue) {
|
||||
q.reject(err);
|
||||
}
|
||||
refreshQueue = [];
|
||||
}
|
||||
|
||||
// ========== 存储辅助函数 ==========
|
||||
|
||||
/** 存储认证令牌 */
|
||||
export function setToken(token: string) {
|
||||
try {
|
||||
localStorage.setItem(LS_TOKEN_KEY, token);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 获取认证令牌 */
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LS_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取刷新令牌 */
|
||||
export function getRefreshToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LS_REFRESH_TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 存储 tokens */
|
||||
export function setTokens(accessToken: string, refreshTokenValue?: string) {
|
||||
setToken(accessToken);
|
||||
if (refreshTokenValue) {
|
||||
try {
|
||||
localStorage.setItem(LS_REFRESH_TOKEN_KEY, refreshTokenValue);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除认证令牌 */
|
||||
export function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(LS_TOKEN_KEY);
|
||||
localStorage.removeItem(LS_REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(LS_USER_ID_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 检查是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// ========== Token 刷新逻辑 ==========
|
||||
|
||||
/**
|
||||
* 尝试刷新 token。如果已经有正在进行的刷新请求,等待它完成。
|
||||
*/
|
||||
async function tryRefreshToken(): Promise<string | null> {
|
||||
const refreshTokenValue = getRefreshToken();
|
||||
if (!refreshTokenValue) {
|
||||
// 没有 refresh token,无法刷新
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果已经有刷新正在进行中,加入队列等待
|
||||
if (isRefreshing && refreshPromise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
refreshQueue.push({ resolve, reject });
|
||||
});
|
||||
}
|
||||
|
||||
isRefreshing = true;
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${refreshTokenValue}`,
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshTokenValue }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
if (data.token) {
|
||||
setTokens(data.token, data.refresh_token);
|
||||
return data.token;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// 刷新失败
|
||||
clearToken();
|
||||
return null;
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
refreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
const newToken = await refreshPromise;
|
||||
onRefreshDone(newToken);
|
||||
return newToken;
|
||||
} catch (err) {
|
||||
onRefreshFailed(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求选项 */
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
@@ -20,70 +165,70 @@ interface ApiResponse<T = unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送API请求
|
||||
* 发送 API 请求,内置 401 自动刷新拦截
|
||||
*/
|
||||
async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
|
||||
const { method = 'GET', body, auth = true } = options;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const makeRequest = async (tokenOverride?: string): Promise<ApiResponse<T>> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (auth) {
|
||||
const token = localStorage.getItem('token');
|
||||
const token = tokenOverride || (auth ? getToken() : null);
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
// 401 且当前请求需要认证、且不是 refresh 接口本身、且尚未重试
|
||||
if (response.status === 401 && auth && endpoint !== '/auth/refresh' && !tokenOverride) {
|
||||
const newToken = await tryRefreshToken();
|
||||
if (newToken) {
|
||||
// 用新 token 重试
|
||||
return makeRequest(newToken);
|
||||
}
|
||||
// 刷新失败,清除状态,跳转登录页
|
||||
clearToken();
|
||||
// 触发页面重新加载以显示登录页
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return {
|
||||
error: '认证已过期,请重新登录',
|
||||
status: 401,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { data: data as T, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: data?.error || `请求失败 (${response.status})`,
|
||||
status: response.status,
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return { data: data as T, status: response.status };
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : '网络错误',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
return makeRequest();
|
||||
}
|
||||
|
||||
/** 存储认证令牌 */
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
}
|
||||
|
||||
/** 获取认证令牌 */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
/** 清除认证令牌 */
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
|
||||
/** 检查是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
// ========== 认证API ==========
|
||||
// ========== 认证 API ==========
|
||||
|
||||
export async function login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/login', {
|
||||
@@ -92,8 +237,8 @@ export async function login(username: string, password: string): Promise<ApiResp
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
@@ -105,8 +250,8 @@ export async function register(username: string, password: string, email: string
|
||||
auth: false,
|
||||
});
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
localStorage.setItem('user_id', resp.data.user_id);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
@@ -114,12 +259,12 @@ export async function register(username: string, password: string, email: string
|
||||
export async function refreshToken(): Promise<ApiResponse<AuthResponse>> {
|
||||
const resp = await request<AuthResponse>('/auth/refresh', { method: 'POST' });
|
||||
if (resp.data?.token) {
|
||||
setToken(resp.data.token);
|
||||
setTokens(resp.data.token, resp.data.refresh_token);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ========== 会话API ==========
|
||||
// ========== 会话 API ==========
|
||||
|
||||
export async function createSession(title?: string) {
|
||||
return request('/sessions', { method: 'POST', body: { title } });
|
||||
@@ -141,7 +286,7 @@ export async function fetchSessionMessages(id: string) {
|
||||
return request(`/sessions/${id}/messages`);
|
||||
}
|
||||
|
||||
// ========== 记忆API ==========
|
||||
// ========== 记忆 API ==========
|
||||
|
||||
export async function searchMemory(query: string) {
|
||||
return request(`/memory/search?q=${encodeURIComponent(query)}`);
|
||||
|
||||
Reference in New Issue
Block a user