Skip to content

Commit ebbe7eb

Browse files
committed
feat(agentrazor): agent 侧支持自动续期 token
1 parent c8c8c40 commit ebbe7eb

3 files changed

Lines changed: 114 additions & 18 deletions

File tree

agent/src/App.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
zhCN,
2222
dateZhCN
2323
} from 'naive-ui';
24-
import { authApi, clearToken, conversationApi, conversationGroupApi, getToken, setAuthErrorHandler, setToken } from './api';
24+
import { authApi, clearRefreshToken, clearToken, conversationApi, conversationGroupApi, getToken, setAuthErrorHandler, setRefreshToken, setToken } from './api';
2525
import type { Conversation, ConversationDetail, StreamEvent, ThreadItem, Turn, UserInfo } from './api';
2626
2727
interface SidebarViewState {
@@ -1260,8 +1260,9 @@ async function submitLogin() {
12601260
if (!username || !password) return;
12611261
loginLoading.value = true;
12621262
try {
1263-
const { token } = await authApi.pwdLogin(username, password);
1263+
const { token, refreshToken } = await authApi.pwdLogin(username, password);
12641264
setToken(token);
1265+
setRefreshToken(refreshToken);
12651266
currentUser.value = await authApi.getUserInfo();
12661267
await loadConversationGroups();
12671268
loginVisible.value = false;
@@ -1278,6 +1279,7 @@ async function submitLogin() {
12781279
function logout() {
12791280
userMenuVisible.value = false;
12801281
clearToken();
1282+
clearRefreshToken();
12811283
currentUser.value = null;
12821284
conversationGroups.value = [];
12831285
closeStream?.();

agent/src/api.ts

Lines changed: 106 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface EventsResponse {
8484
const apiBase = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '');
8585

8686
const TOKEN_KEY = 'agentrazor_token';
87+
const REFRESH_TOKEN_KEY = 'agentrazor_refresh_token';
8788
export function getToken() {
8889
return localStorage.getItem(TOKEN_KEY) || '';
8990
}
@@ -93,6 +94,15 @@ export function setToken(token: string) {
9394
export function clearToken() {
9495
localStorage.removeItem(TOKEN_KEY);
9596
}
97+
export function getRefreshToken() {
98+
return localStorage.getItem(REFRESH_TOKEN_KEY) || '';
99+
}
100+
export function setRefreshToken(refreshToken: string) {
101+
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
102+
}
103+
export function clearRefreshToken() {
104+
localStorage.removeItem(REFRESH_TOKEN_KEY);
105+
}
96106

97107
// authErrorHandler is invoked when a request returns 401, so the UI can reset
98108
// to the logged-out state.
@@ -101,7 +111,49 @@ export function setAuthErrorHandler(fn: (() => void) | null) {
101111
authErrorHandler = fn;
102112
}
103113

104-
async function request<T>(path: string, init?: RequestInit): Promise<T> {
114+
// 刷新 token 的并发单飞:多个请求同时 401 时只发起一次刷新,成功后一起重试
115+
let refreshTokenPromise: Promise<boolean> | null = null;
116+
117+
async function refreshAccessToken(): Promise<boolean> {
118+
if (!refreshTokenPromise) {
119+
refreshTokenPromise = (async () => {
120+
const refreshToken = getRefreshToken();
121+
if (!refreshToken) return false;
122+
try {
123+
const response = await fetch(`${apiBase}/api/v1/auth/refreshToken`, {
124+
method: 'POST',
125+
headers: { 'Content-Type': 'application/json' },
126+
body: JSON.stringify({ refreshToken })
127+
});
128+
const body = (await response.json().catch(() => null)) as Envelope<LoginResponse> | LoginResponse | null;
129+
const envelope = body !== null && isEnvelope<LoginResponse>(body) ? body : null;
130+
// 40102 = refresh token 过期,属于登录态失效,不做续期
131+
if (envelope?.code === 40102) return false;
132+
const data = envelope ? envelope.data : (body as LoginResponse | null);
133+
if (!data || !data.token || !data.refreshToken) return false;
134+
setToken(data.token);
135+
setRefreshToken(data.refreshToken);
136+
return true;
137+
} catch {
138+
return false;
139+
}
140+
})();
141+
refreshTokenPromise.finally(() => {
142+
setTimeout(() => {
143+
refreshTokenPromise = null;
144+
}, 1000);
145+
});
146+
}
147+
return refreshTokenPromise;
148+
}
149+
150+
function expireSession() {
151+
clearToken();
152+
clearRefreshToken();
153+
authErrorHandler?.();
154+
}
155+
156+
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
105157
const token = getToken();
106158
const response = await fetch(`${apiBase}${path}`, {
107159
...init,
@@ -111,22 +163,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
111163
...init?.headers
112164
}
113165
});
114-
if (response.status === 401) {
115-
clearToken();
116-
authErrorHandler?.();
166+
const body = (await response.json().catch(() => null)) as Envelope<T> | T | null;
167+
const envelope = body !== null && isEnvelope<T>(body) ? body : null;
168+
// 鉴权失败:HTTP 401(未来兼容)或业务码 40101(access token 过期,可刷新)
169+
const authFailed = response.status === 401 || envelope?.code === 40101;
170+
if (authFailed) {
171+
// 先尝试用 refreshToken 续期,成功则带新 token 重试一次
172+
if (!retried && (await refreshAccessToken())) {
173+
return request<T>(path, init, true);
174+
}
175+
expireSession();
117176
throw new Error('登录已过期,请重新登录');
118177
}
119-
const body = (await response.json()) as Envelope<T> | T;
120178
if (!response.ok) {
121179
throw new Error(`请求失败(${response.status})`);
122180
}
123-
if (isEnvelope<T>(body)) {
124-
if (body.code !== 200) {
125-
throw new Error(body.msg || '服务请求失败');
181+
if (envelope) {
182+
if (envelope.code !== 200) {
183+
// 40102 = refresh token 过期,登录态失效,直接登出
184+
if (envelope.code === 40102) expireSession();
185+
throw new Error(envelope.msg || '服务请求失败');
126186
}
127-
return body.data;
187+
return envelope.data;
128188
}
129-
return body;
189+
return body as T;
130190
}
131191

132192
function isEnvelope<T>(body: Envelope<T> | T): body is Envelope<T> {
@@ -168,14 +228,31 @@ export const conversationApi = {
168228
});
169229
},
170230
subscribe(id: string, onEvent: (event: StreamEvent) => void, onError: () => void) {
171-
const token = getToken();
172231
// EventSource 无法携带 Authorization 头,改用 fetch 流式读取 SSE,
173232
// 这样鉴权与其他接口保持一致(Bearer token 走 Authorization)。
174233
const controller = new AbortController();
175234
const url = `${apiBase}/api/v1/conversations/${encodeURIComponent(id)}/events`;
235+
// 断线重试标记:一次断开最多自动重连一次,避免死循环
236+
let retried = false;
176237

177-
void (async () => {
238+
// 断线后静默重连:先刷新 token(可能已过期),再用新 token 重新连接
239+
const reconnect = async (): Promise<boolean> => {
240+
if (controller.signal.aborted || retried) return false;
241+
retried = true;
242+
await new Promise(resolve => setTimeout(resolve, 1000));
243+
if (!getRefreshToken()) return false;
244+
if (!(await refreshAccessToken())) {
245+
expireSession();
246+
return false;
247+
}
248+
await connect();
249+
return true;
250+
};
251+
252+
const connect = async (): Promise<void> => {
253+
if (controller.signal.aborted) return;
178254
try {
255+
const token = getToken();
179256
const response = await fetch(url, {
180257
headers: {
181258
Accept: 'text/event-stream',
@@ -184,10 +261,16 @@ export const conversationApi = {
184261
cache: 'no-store',
185262
signal: controller.signal
186263
});
187-
if (!response.ok || !response.body) {
188-
onError();
264+
// 鉴权失败时服务端返回 HTTP 200 + JSON envelope(如 code 40101),
265+
// 用 Content-Type 区分:SSE 是 text/event-stream,失败响应是 JSON
266+
const contentType = response.headers.get('content-type') || '';
267+
if (!response.ok || !response.body || !contentType.includes('text/event-stream')) {
268+
if (await reconnect()) return;
269+
if (!controller.signal.aborted) onError();
189270
return;
190271
}
272+
// 连接成功,重置重试标记:后续再次断开仍可自动重连
273+
retried = false;
191274
const reader = response.body.getReader();
192275
const decoder = new TextDecoder();
193276
let buffer = '';
@@ -213,11 +296,18 @@ export const conversationApi = {
213296
}
214297
}
215298
}
299+
// 流结束(服务端空闲回收/重启等):自动重连保持事件不中断
300+
if (await reconnect()) return;
301+
if (!controller.signal.aborted) onError();
216302
} catch (error) {
217-
if ((error as Error)?.name !== 'AbortError') onError();
303+
if ((error as Error)?.name !== 'AbortError') {
304+
if (await reconnect()) return;
305+
if (!controller.signal.aborted) onError();
306+
}
218307
}
219-
})();
308+
};
220309

310+
void connect();
221311
return () => controller.abort();
222312
}
223313
};

server/etc/etc.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ rest:
77
port: 8001
88
timeout: 20000
99

10+
jwt:
11+
accessExpire: 1800
12+
refreshExpire: 2592000
13+
1014
log:
1115
encoding: plain
1216
level: info

0 commit comments

Comments
 (0)