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 {
|
class HttpClient {
|
||||||
private client: AxiosInstance;
|
private client: AxiosInstance;
|
||||||
|
// 防并发刷新锁:多个请求同时 401 时只发一次 refresh
|
||||||
|
private refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
|
|
@ -21,7 +23,6 @@ class HttpClient {
|
||||||
// Request 拦截器:注入 Bearer token
|
// Request 拦截器:注入 Bearer token
|
||||||
this.client.interceptors.request.use((config) => {
|
this.client.interceptors.request.use((config) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// 从 Zustand persisted storage 读取 token(避免直接耦合 store 模块导致循环依赖)
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('gcx-admin-auth');
|
const raw = localStorage.getItem('gcx-admin-auth');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
|
|
@ -37,20 +38,68 @@ class HttpClient {
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Response 拦截器:401 → 清空 auth store 并跳转登录
|
// Response 拦截器:401 → 尝试 refresh token,成功则重试;失败才跳登录
|
||||||
this.client.interceptors.response.use(
|
this.client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401 && typeof window !== 'undefined') {
|
const originalRequest = error.config;
|
||||||
// 清空 Zustand persisted auth
|
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');
|
localStorage.removeItem('gcx-admin-auth');
|
||||||
window.location.href = '/login';
|
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> {
|
async get<T = unknown>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
const response: AxiosResponse = await this.client.get(url, config);
|
const response: AxiosResponse = await this.client.get(url, config);
|
||||||
return response.data?.data ?? response.data;
|
return response.data?.data ?? response.data;
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,15 @@ interface AuthState {
|
||||||
// State
|
// State
|
||||||
user: AdminUser | null;
|
user: AdminUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
/** 内部:token refresh 成功后更新 accessToken */
|
||||||
|
_updateToken: (accessToken: string) => void;
|
||||||
/** 内部:Zustand persist 重水合后调用,同步 isAuthenticated */
|
/** 内部:Zustand persist 重水合后调用,同步 isAuthenticated */
|
||||||
_onRehydrate: () => void;
|
_onRehydrate: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +33,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
|
@ -38,6 +42,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
token: tokens.accessToken,
|
token: tokens.accessToken,
|
||||||
|
refreshToken: tokens.refreshToken,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
@ -45,7 +50,11 @@ export const useAuthStore = create<AuthState>()(
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
authRepository.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: () => {
|
_onRehydrate: () => {
|
||||||
|
|
@ -66,8 +75,7 @@ export const useAuthStore = create<AuthState>()(
|
||||||
}
|
}
|
||||||
return localStorage;
|
return localStorage;
|
||||||
}),
|
}),
|
||||||
// 只持久化 user 和 token,不持久化 isLoading/isAuthenticated(运行时计算)
|
partialize: (state) => ({ user: state.user, token: state.token, refreshToken: state.refreshToken }),
|
||||||
partialize: (state) => ({ user: state.user, token: state.token }),
|
|
||||||
onRehydrateStorage: () => (state) => {
|
onRehydrateStorage: () => (state) => {
|
||||||
if (state) state._onRehydrate();
|
if (state) state._onRehydrate();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -299,17 +299,40 @@ const UploadModal: React.FC<{
|
||||||
const [changelog, setChangelog] = useState('');
|
const [changelog, setChangelog] = useState('');
|
||||||
const [minOsVersion, setMinOsVersion] = useState('');
|
const [minOsVersion, setMinOsVersion] = useState('');
|
||||||
const [isForceUpdate, setIsForceUpdate] = useState(false);
|
const [isForceUpdate, setIsForceUpdate] = useState(false);
|
||||||
|
const [parsing, setParsing] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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];
|
const f = e.target.files?.[0];
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
setFile(f);
|
setFile(f);
|
||||||
setError('');
|
setError('');
|
||||||
|
setParseWarning('');
|
||||||
|
|
||||||
// Auto-detect platform from extension
|
// Auto-detect platform from extension
|
||||||
if (f.name.endsWith('.apk')) setPlatform('ANDROID');
|
if (f.name.endsWith('.apk')) setPlatform('ANDROID');
|
||||||
else if (f.name.endsWith('.ipa')) setPlatform('IOS');
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
|
|
@ -320,6 +343,7 @@ const UploadModal: React.FC<{
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
console.log('[Upload] Starting upload:', file.name, (file.size / 1024 / 1024).toFixed(1) + 'MB');
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('appType', appType);
|
formData.append('appType', appType);
|
||||||
|
|
@ -330,11 +354,13 @@ const UploadModal: React.FC<{
|
||||||
if (minOsVersion) formData.append('minOsVersion', minOsVersion);
|
if (minOsVersion) formData.append('minOsVersion', minOsVersion);
|
||||||
formData.append('isForceUpdate', String(isForceUpdate));
|
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,
|
timeout: 300000,
|
||||||
});
|
});
|
||||||
|
console.log('[Upload] Success:', result);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.error('[Upload] Failed:', err);
|
||||||
setError(err?.response?.data?.message || err?.message || 'Upload failed');
|
setError(err?.response?.data?.message || err?.message || 'Upload failed');
|
||||||
}
|
}
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|
@ -356,6 +382,8 @@ const UploadModal: React.FC<{
|
||||||
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
|
{file.name} ({(file.size / 1024 / 1024).toFixed(1)} MB)
|
||||||
</div>
|
</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>
|
<label style={labelStyle}>{t('app_version_platform')}</label>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<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>}
|
{error && <div style={{ color: 'var(--color-error)', font: 'var(--text-body-sm)', marginTop: 8 }}>{error}</div>}
|
||||||
{uploading && (
|
{uploading && (
|
||||||
<div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>
|
<div style={{ font: 'var(--text-caption)', color: 'var(--color-info)', marginTop: 4 }}>
|
||||||
{t('app_version_uploading_hint') || '正在上传,大文件需要较长时间,请耐心等待...'}
|
正在上传,大文件需要较长时间,请耐心等待...
|
||||||
</div>
|
</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)' }}>
|
<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')}
|
{t('cancel')}
|
||||||
</button>
|
</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')}
|
{uploading ? t('app_version_uploading') : t('confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue