import axios, { AxiosError } from 'axios'; import type { User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail, AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo, LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem, } from '../types'; import { reportError } from './logCenter'; const api = axios.create({ baseURL: '/api/v1', headers: { 'Content-Type': 'application/json' }, }); // Request interceptor: attach access token api.interceptors.request.use((config) => { const token = localStorage.getItem('access_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); // Response interceptor: auto-refresh on 401 api.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; const requestUrl = originalRequest?.url || ''; const authEndpoints = ['/auth/login', '/auth/register', '/auth/token/refresh']; const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep)); // Check special ban/kick codes on 401 or 403 const errorCode = error.response?.data?.code || error.response?.data?.detail?.code; if ((error.response?.status === 401 || error.response?.status === 403) && !isAuthEndpoint) { if (errorCode === 'session_expired_other_device') { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); alert('您的账号已在其他设备登录,请重新登录'); window.location.href = '/login'; return Promise.reject(error); } if (errorCode === 'user_disabled') { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); alert('您的账号已被禁用,请联系团队管理员'); window.location.href = '/login'; return Promise.reject(error); } if (errorCode === 'team_disabled') { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); alert('您所在的团队已被禁用,请联系平台管理员'); window.location.href = '/login'; return Promise.reject(error); } } // Auto-refresh on 401 (only for non-ban cases) if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) { originalRequest._retry = true; const refreshToken = localStorage.getItem('refresh_token'); if (refreshToken) { try { const { data } = await axios.post('/api/v1/auth/token/refresh', { refresh: refreshToken, }); localStorage.setItem('access_token', data.access); if (data.refresh) { localStorage.setItem('refresh_token', data.refresh); } originalRequest.headers.Authorization = `Bearer ${data.access}`; return api(originalRequest); } catch { localStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); window.location.href = '/login'; } } else { window.location.href = '/login'; } } // Report 5xx server errors to Log Center if (error.response && error.response.status >= 500) { reportError(error instanceof Error ? error : new Error(String(error)), { api_url: (error as AxiosError).config?.url, method: (error as AxiosError).config?.method, status: error.response.status, }); } return Promise.reject(error); } ); // Auth APIs export const authApi = { login: (username: string, password: string) => api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }), refreshToken: (refresh: string) => api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }), getMe: () => api.get('/auth/me'), changePassword: (oldPassword: string, newPassword: string) => api.post('/auth/change-password', { old_password: oldPassword, new_password: newPassword }), logout: () => api.post('/auth/logout'), }; // Media upload API export const mediaApi = { upload: (file: File) => { const formData = new FormData(); formData.append('file', file); return api.post<{ url: string; type: 'image' | 'video'; filename: string; size: number; }>('/media/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); }, }; // Video generation API export const videoApi = { generate: (data: { prompt: string; mode: string; model: string; aspect_ratio: string; duration: number; references: { url: string; type: string; role: string; label: string; thumb_url?: string }[]; search_mode?: string; seed?: number; }) => api.post<{ task_id: string; ark_task_id: string; status: string; estimated_time: number; seconds_consumed: number; error_message: string; }>('/video/generate', data), getTasks: (params?: { page_size?: number; offset?: number }) => api.get<{ results: BackendTask[]; total: number; has_more: boolean }>('/video/tasks', { params }), getTaskStatus: (taskId: string) => api.get(`/video/tasks/${taskId}`), deleteTask: (taskId: string) => api.delete(`/video/tasks/${taskId}`), toggleFavorite: (taskId: string) => api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`), getAnnouncement: () => api.get<{ announcement: string; enabled: boolean }>('/announcement'), }; // Admin APIs (Super Admin) export const adminApi = { getStats: () => api.get('/admin/stats'), // Team management getTeams: () => api.get<{ results: Team[] }>('/admin/teams'), createTeam: (data: { name: string; monthly_spending_limit?: number; daily_member_limit_default?: number; expected_regions: string; markup_percentage?: number }) => api.post('/admin/teams/create', data), getTeamDetail: (teamId: number) => api.get(`/admin/teams/${teamId}`), updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; max_concurrent_tasks?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial }) => api.put(`/admin/teams/${teamId}`, data), topUpTeam: (teamId: number, amount: number) => api.post(`/admin/teams/${teamId}/topup`, { amount }), setTeamPool: (teamId: number, balance: number) => api.put(`/admin/teams/${teamId}/set-pool`, { balance }), createTeamAdmin: (teamId: number, data: { username: string; email: string; password: string }) => api.post(`/admin/teams/${teamId}/admin`, data), // User management createUser: (data: { username: string; email: string; password: string; daily_generation_limit?: number; monthly_generation_limit?: number; is_staff?: boolean; }) => api.post('/admin/users/create', data), getUsers: (params: { page?: number; page_size?: number; search?: string; status?: string; team_id?: number; } = {}) => api.get>('/admin/users', { params }), getUserDetail: (userId: number) => api.get(`/admin/users/${userId}`), updateUserQuota: (userId: number, daily: number, monthly: number, spendingLimit?: number) => api.put(`/admin/users/${userId}/quota`, { daily_generation_limit: daily, monthly_generation_limit: monthly, ...(spendingLimit !== undefined && { spending_limit: spendingLimit }), }), updateUserStatus: (userId: number, isActive: boolean) => api.patch(`/admin/users/${userId}/status`, { is_active: isActive }), resetUserPassword: (userId: number, newPassword: string) => api.post(`/admin/users/${userId}/reset-password`, { new_password: newPassword }), getRecords: (params: { page?: number; page_size?: number; search?: string; start_date?: string; end_date?: string; team_id?: number; } = {}) => api.get>('/admin/records', { params }), getSettings: () => api.get('/admin/settings'), updateSettings: (settings: SystemSettings) => api.put('/admin/settings', settings), // Content Assets getAssetsOverview: () => api.get<{ total_videos: number; total_seconds: number; total_teams: number; teams: AssetTeamSummary[]; no_team: { video_count: number; seconds_consumed: number }; }>('/admin/assets/overview'), getAssetsTeamMembers: (teamId: number) => api.get<{ team_id: number; team_name: string; total_videos: number; total_seconds: number; member_count: number; members: AssetMemberSummary[]; }>(`/admin/assets/team/${teamId}/members`), getAssetsUserVideos: (userId: number, page: number = 1, pageSize: number = 30) => api.get<{ user_id: number; username: string; total: number; page: number; page_size: number; results: AssetVideo[]; }>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }), // Anomaly detection getLoginAnomalies: (params: { page?: number; page_size?: number; team_id?: number; rule?: string; level?: string; start_date?: string; end_date?: string; } = {}) => api.get & { total_pages: number }>('/admin/anomalies', { params }), testFeishu: (mobile: string) => api.post<{ message: string }>('/admin/test-feishu', { mobile }), testSms: (mobile: string) => api.post<{ message: string }>('/admin/test-sms', { mobile }), teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) => api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>( `/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount } ), teamApplyLearnedRegions: (teamId: number, cities: string[]) => api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }), getLoginRecords: (params: { page?: number; page_size?: number; search?: string; team_id?: string; start_date?: string; end_date?: string; city?: string; } = {}) => api.get('/admin/login-records', { params }), getAuditLogs: (params: { page?: number; page_size?: number; action?: string; operator?: string; start_date?: string; end_date?: string; } = {}) => api.get & { total_pages: number }>('/admin/logs', { params }), }; // Team Admin APIs export const teamApi = { getInfo: () => api.get('/team/info'), getStats: () => api.get('/team/stats'), getMembers: () => api.get<{ results: TeamMember[] }>('/team/members'), createMember: (data: { username: string; password: string; daily_generation_limit?: number; monthly_generation_limit?: number }) => api.post('/team/members/create', data), getMemberDetail: (memberId: number) => api.get('/team/members/' + memberId), updateMemberQuota: (memberId: number, daily: number, monthly: number, spendingLimit?: number) => api.put(`/team/members/${memberId}/quota`, { daily_generation_limit: daily, monthly_generation_limit: monthly, ...(spendingLimit !== undefined && { spending_limit: spendingLimit }), }), updateMemberStatus: (memberId: number, isActive: boolean) => api.patch(`/team/members/${memberId}/status`, { is_active: isActive }), // Content Assets getAssetsOverview: () => api.get<{ team_id: number; team_name: string; total_videos: number; total_seconds: number; member_count: number; members: AssetMemberSummary[]; }>('/team/assets/overview'), getAssetsMemberVideos: (memberId: number, page: number = 1, pageSize: number = 30) => api.get<{ user_id: number; username: string; total: number; page: number; page_size: number; results: AssetVideo[]; }>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }), // Consumption Records getRecords: (params: { page?: number; page_size?: number; search?: string; start_date?: string; end_date?: string; } = {}) => api.get<{ total: number; page: number; page_size: number; results: AdminRecord[] }>('/team/records', { params }), }; // Profile APIs export const profileApi = { getOverview: (period: '7d' | '30d' = '7d') => api.get('/profile/overview', { params: { period } }), getRecords: (page: number = 1, pageSize: number = 20) => api.get>('/profile/records', { params: { page, page_size: pageSize }, }), }; export const assetsApi = { getGroups: (params: { page?: number; page_size?: number } = {}) => api.get<{ results: AssetGroup[]; total: number }>('/assets/groups', { params }), createGroup: (data: FormData) => api.post('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }), getGroupDetail: (id: number) => api.get(`/assets/groups/${id}`), updateGroup: (id: number, data: { name?: string; description?: string }) => api.put(`/assets/groups/${id}`, data), addAsset: (groupId: number, data: FormData) => api.post(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }), updateAsset: (id: number, data: { name: string }) => api.put(`/assets/${id}`, data), search: (q: string) => api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }), pollStatus: (id: number) => api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`), }; /** * Append TOS image resize parameter to reduce loading size. * Only applies to TOS image URLs (volces.com with image extensions). */ export function tosThumb(url: string | undefined, height: number): string { if (!url) return ''; // 只对我们自己的 TOS 桶生效(airdrama-media),不处理火山内部桶(ark-media-asset 等) if (!url.includes('airdrama-media')) return url; if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url; const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}x-tos-process=image/resize,h_${height}`; } export default api;