①用户总消费额度(User.spending_limit,默认-1不限,花完即停,含冻结中任务) ②团队并发任务控制(Team.max_concurrent_tasks,默认5,超限拒绝) ③额度检查竞态修复(Layer 1-4 全部移入 transaction.atomic + select_for_update) ④查询参数类型保护(_safe_int 替换所有裸 int() 调用,防 500) ⑤团管消费记录页(/team/records,按用户/日期筛选 + CSV 导出) ⑥超管用户页/团管成员页新增总额度列和编辑 ⑦超管团队页新增并发列和内联编辑 ⑧失败原因 tooltip 改右对齐防裁剪 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
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);
|
||
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 }>('/auth/token/refresh', { refresh }),
|
||
|
||
getMe: () =>
|
||
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/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;
|
||
}) =>
|
||
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<BackendTask>(`/video/tasks/${taskId}`),
|
||
|
||
deleteTask: (taskId: string) =>
|
||
api.delete(`/video/tasks/${taskId}`),
|
||
|
||
getAnnouncement: () =>
|
||
api.get<{ announcement: string; enabled: boolean }>('/announcement'),
|
||
};
|
||
|
||
// Admin APIs (Super Admin)
|
||
export const adminApi = {
|
||
getStats: () =>
|
||
api.get<AdminStats>('/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<TeamDetail>(`/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<TeamAnomalyConfig> }) =>
|
||
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<PaginatedResponse<AdminUser>>('/admin/users', { params }),
|
||
|
||
getUserDetail: (userId: number) =>
|
||
api.get<AdminUserDetail>(`/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<PaginatedResponse<AdminRecord>>('/admin/records', { params }),
|
||
|
||
getSettings: () =>
|
||
api.get<SystemSettings>('/admin/settings'),
|
||
|
||
updateSettings: (settings: SystemSettings) =>
|
||
api.put<SystemSettings & { updated_at: string }>('/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<PaginatedResponse<LoginAnomaly> & { total_pages: number }>('/admin/anomalies', { params }),
|
||
|
||
testFeishu: (mobile: string) =>
|
||
api.post<{ message: string }>('/admin/test-feishu', { 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<PaginatedResponse<AuditLog> & { total_pages: number }>('/admin/logs', { params }),
|
||
};
|
||
|
||
// Team Admin APIs
|
||
export const teamApi = {
|
||
getInfo: () =>
|
||
api.get<TeamInfo & { daily_member_limit_default: number; member_count: number }>('/team/info'),
|
||
|
||
getStats: () =>
|
||
api.get<TeamStats>('/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<ProfileOverview>('/profile/overview', { params: { period } }),
|
||
|
||
getRecords: (page: number = 1, pageSize: number = 20) =>
|
||
api.get<PaginatedResponse<AdminRecord>>('/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<AssetGroup>('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }),
|
||
getGroupDetail: (id: number) =>
|
||
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
|
||
updateGroup: (id: number, data: { name?: string; description?: string }) =>
|
||
api.put(`/assets/groups/${id}`, data),
|
||
addAsset: (groupId: number, data: FormData) =>
|
||
api.post<AssetItem>(`/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;
|