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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-06 10:18:31 -08:00
parent 9a40769e0d
commit 07c171ce22
3 changed files with 98 additions and 13 deletions

View File

@ -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<string> | 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<string> {
// 多个并发 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 中的 tokenZustand 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<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse = await this.client.get(url, config);
return response.data?.data ?? response.data;

View File

@ -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<void>;
logout: () => void;
/** 内部token refresh 成功后更新 accessToken */
_updateToken: (accessToken: string) => void;
/** 内部Zustand persist 重水合后调用,同步 isAuthenticated */
_onRehydrate: () => void;
}
@ -30,6 +33,7 @@ export const useAuthStore = create<AuthState>()(
(set, get) => ({
user: null,
token: null,
refreshToken: null,
isLoading: true,
isAuthenticated: false,
@ -38,6 +42,7 @@ export const useAuthStore = create<AuthState>()(
set({
user,
token: tokens.accessToken,
refreshToken: tokens.refreshToken,
isAuthenticated: true,
isLoading: false,
});
@ -45,7 +50,11 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
}
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();
},

View File

@ -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<HTMLInputElement>) => {
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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)
</div>
)}
{parsing && <div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>{t('app_version_parsing')}</div>}
{parseWarning && <div style={{ font: 'var(--text-caption)', color: 'var(--color-warning, #f90)', marginTop: 4 }}>{parseWarning}</div>}
<label style={labelStyle}>{t('app_version_platform')}</label>
<div style={{ display: 'flex', gap: 12 }}>
@ -397,7 +425,7 @@ const UploadModal: React.FC<{
{error && <div style={{ color: 'var(--color-error)', font: 'var(--text-body-sm)', marginTop: 8 }}>{error}</div>}
{uploading && (
<div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>
{t('app_version_uploading_hint') || '正在上传,大文件需要较长时间,请耐心等待...'}
...
</div>
)}
@ -405,7 +433,7 @@ const UploadModal: React.FC<{
<button onClick={onClose} style={{ padding: '8px 20px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-full)', background: 'transparent', cursor: 'pointer', font: 'var(--text-label-sm)' }}>
{t('cancel')}
</button>
<button onClick={handleSubmit} disabled={uploading} style={{ ...primaryBtn, opacity: uploading ? 0.6 : 1 }}>
<button onClick={handleSubmit} disabled={uploading || parsing} style={{ ...primaryBtn, opacity: uploading || parsing ? 0.6 : 1 }}>
{uploading ? t('app_version_uploading') : t('confirm')}
</button>
</div>