seaislee1209 203603f69a feat: v0.12.0 用户总额度 + 并发控制 + 团管消费记录 + 安全加固
①用户总消费额度(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>
2026-03-22 18:53:56 +08:00

416 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;