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:
parent
9a40769e0d
commit
07c171ce22
|
|
@ -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 中的 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<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
const response: AxiosResponse = await this.client.get(url, config);
|
||||
return response.data?.data ?? response.data;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue