From 07c171ce22a78ed42b38cc643012a5c1b51530ac Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 6 Mar 2026 10:18:31 -0800 Subject: [PATCH] fix(admin-web): auto token refresh + restore APK parse with warnings - auth.store: persist refreshToken alongside accessToken - http.client: on 401, auto-refresh token and retry original request with mutex lock to prevent concurrent refresh calls; only redirect to /login if refresh itself fails - upload modal: restore auto-parse on file select; show warning if parse fails; add console logs for debugging; fix button disabled during parsing Co-Authored-By: Claude Sonnet 4.6 --- .../src/infrastructure/http/http.client.ts | 61 +++++++++++++++++-- .../admin-web/src/store/zustand/auth.store.ts | 14 ++++- .../app-versions/AppVersionManagementPage.tsx | 36 +++++++++-- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/frontend/admin-web/src/infrastructure/http/http.client.ts b/frontend/admin-web/src/infrastructure/http/http.client.ts index 0674315..e8c9e09 100644 --- a/frontend/admin-web/src/infrastructure/http/http.client.ts +++ b/frontend/admin-web/src/infrastructure/http/http.client.ts @@ -10,6 +10,8 @@ const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.gogenex.com class HttpClient { private client: AxiosInstance; + // 防并发刷新锁:多个请求同时 401 时只发一次 refresh + private refreshPromise: Promise | null = null; constructor() { this.client = axios.create({ @@ -21,7 +23,6 @@ class HttpClient { // Request 拦截器:注入 Bearer token this.client.interceptors.request.use((config) => { if (typeof window !== 'undefined') { - // 从 Zustand persisted storage 读取 token(避免直接耦合 store 模块导致循环依赖) try { const raw = localStorage.getItem('gcx-admin-auth'); if (raw) { @@ -37,20 +38,68 @@ class HttpClient { return config; }); - // Response 拦截器:401 → 清空 auth store 并跳转登录 + // Response 拦截器:401 → 尝试 refresh token,成功则重试;失败才跳登录 this.client.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401 && typeof window !== 'undefined') { - // 清空 Zustand persisted auth + async (error) => { + const originalRequest = error.config; + if (error.response?.status !== 401 || originalRequest._retry || typeof window === 'undefined') { + return Promise.reject(error); + } + + // 标记已重试,防止无限循环 + originalRequest._retry = true; + + try { + console.log('[HttpClient] 401 detected, attempting token refresh...'); + const newToken = await this.refreshAccessToken(); + console.log('[HttpClient] Token refreshed, retrying:', originalRequest.url); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return this.client(originalRequest); + } catch (refreshErr) { + // refresh 也失败 → 清空登录态,跳转登录 + console.error('[HttpClient] Token refresh failed, redirecting to login:', refreshErr); localStorage.removeItem('gcx-admin-auth'); window.location.href = '/login'; + return Promise.reject(error); } - return Promise.reject(error); }, ); } + private async refreshAccessToken(): Promise { + // 多个并发 401 请求共享同一个 refresh Promise,避免重复刷新 + if (this.refreshPromise) return this.refreshPromise; + + this.refreshPromise = (async () => { + try { + const raw = localStorage.getItem('gcx-admin-auth'); + if (!raw) throw new Error('No auth state'); + const { state } = JSON.parse(raw); + if (!state?.refreshToken) throw new Error('No refresh token'); + + console.log('[HttpClient] Calling refresh endpoint...'); + const resp = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, { + refreshToken: state.refreshToken, + }); + const newAccessToken: string = resp.data?.data?.accessToken ?? resp.data?.accessToken; + if (!newAccessToken) throw new Error('No access token in refresh response'); + + console.log('[HttpClient] Got new access token, updating storage...'); + // 更新 localStorage 中的 token(Zustand store 下次读取时自动感知) + const updated = JSON.parse(raw); + updated.state.token = newAccessToken; + localStorage.setItem('gcx-admin-auth', JSON.stringify(updated)); + + return newAccessToken; + } finally { + this.refreshPromise = null; + } + })(); + + return this.refreshPromise; + } + async get(url: string, config?: AxiosRequestConfig): Promise { const response: AxiosResponse = await this.client.get(url, config); return response.data?.data ?? response.data; diff --git a/frontend/admin-web/src/store/zustand/auth.store.ts b/frontend/admin-web/src/store/zustand/auth.store.ts index 919c570..4c61804 100644 --- a/frontend/admin-web/src/store/zustand/auth.store.ts +++ b/frontend/admin-web/src/store/zustand/auth.store.ts @@ -15,12 +15,15 @@ interface AuthState { // State user: AdminUser | null; token: string | null; + refreshToken: string | null; isLoading: boolean; isAuthenticated: boolean; // Actions login: (email: string, password: string) => Promise; logout: () => void; + /** 内部:token refresh 成功后更新 accessToken */ + _updateToken: (accessToken: string) => void; /** 内部:Zustand persist 重水合后调用,同步 isAuthenticated */ _onRehydrate: () => void; } @@ -30,6 +33,7 @@ export const useAuthStore = create()( (set, get) => ({ user: null, token: null, + refreshToken: null, isLoading: true, isAuthenticated: false, @@ -38,6 +42,7 @@ export const useAuthStore = create()( set({ user, token: tokens.accessToken, + refreshToken: tokens.refreshToken, isAuthenticated: true, isLoading: false, }); @@ -45,7 +50,11 @@ export const useAuthStore = create()( logout: () => { authRepository.logout(); - set({ user: null, token: null, isAuthenticated: false }); + set({ user: null, token: null, refreshToken: null, isAuthenticated: false }); + }, + + _updateToken: (accessToken: string) => { + set({ token: accessToken }); }, _onRehydrate: () => { @@ -66,8 +75,7 @@ export const useAuthStore = create()( } return localStorage; }), - // 只持久化 user 和 token,不持久化 isLoading/isAuthenticated(运行时计算) - partialize: (state) => ({ user: state.user, token: state.token }), + partialize: (state) => ({ user: state.user, token: state.token, refreshToken: state.refreshToken }), onRehydrateStorage: () => (state) => { if (state) state._onRehydrate(); }, diff --git a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx index ec89949..4037b80 100644 --- a/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx +++ b/frontend/admin-web/src/views/app-versions/AppVersionManagementPage.tsx @@ -299,17 +299,40 @@ const UploadModal: React.FC<{ const [changelog, setChangelog] = useState(''); const [minOsVersion, setMinOsVersion] = useState(''); const [isForceUpdate, setIsForceUpdate] = useState(false); + const [parsing, setParsing] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(''); + const [parseWarning, setParseWarning] = useState(''); - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { const f = e.target.files?.[0]; if (!f) return; setFile(f); setError(''); + setParseWarning(''); + // Auto-detect platform from extension if (f.name.endsWith('.apk')) setPlatform('ANDROID'); else if (f.name.endsWith('.ipa')) setPlatform('IOS'); + + // Auto-parse to pre-fill form fields + setParsing(true); + try { + console.log('[Upload] Parsing package:', f.name, (f.size / 1024 / 1024).toFixed(1) + 'MB'); + const formData = new FormData(); + formData.append('file', f); + const info = await apiClient.post<{ + versionCode?: number; versionName?: string; minSdkVersion?: string; + }>('/api/v1/admin/versions/parse', formData, { timeout: 120000 }); + console.log('[Upload] Parse result:', info); + if (info?.versionName) setVersionName(info.versionName); + if (info?.versionCode) setBuildNumber(String(info.versionCode)); + if (info?.minSdkVersion) setMinOsVersion(info.minSdkVersion); + } catch (err: any) { + console.warn('[Upload] Parse failed:', err?.message); + setParseWarning('无法自动解析安装包信息,请手动填写版本号'); + } + setParsing(false); }; const handleSubmit = async () => { @@ -320,6 +343,7 @@ const UploadModal: React.FC<{ setUploading(true); setError(''); try { + console.log('[Upload] Starting upload:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB'); const formData = new FormData(); formData.append('file', file); formData.append('appType', appType); @@ -330,11 +354,13 @@ const UploadModal: React.FC<{ if (minOsVersion) formData.append('minOsVersion', minOsVersion); formData.append('isForceUpdate', String(isForceUpdate)); - await apiClient.post('/api/v1/admin/versions/upload', formData, { + const result = await apiClient.post('/api/v1/admin/versions/upload', formData, { timeout: 300000, }); + console.log('[Upload] Success:', result); onSuccess(); } catch (err: any) { + console.error('[Upload] Failed:', err); setError(err?.response?.data?.message || err?.message || 'Upload failed'); } setUploading(false); @@ -356,6 +382,8 @@ const UploadModal: React.FC<{ {file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB) )} + {parsing &&
{t('app_version_parsing')}
} + {parseWarning &&
{parseWarning}
}
@@ -397,7 +425,7 @@ const UploadModal: React.FC<{ {error &&
{error}
} {uploading && (
- {t('app_version_uploading_hint') || '正在上传,大文件需要较长时间,请耐心等待...'} + 正在上传,大文件需要较长时间,请耐心等待...
)} @@ -405,7 +433,7 @@ const UploadModal: React.FC<{ -