86 Commits
Author SHA1 Message Date
root e06a739cdd 1 2026-08-14 16:36:11 +08:00
root fe57416493 1 2026-08-14 16:19:07 +08:00
root c47fac821c 1 2026-08-14 16:16:30 +08:00
root a4417e8976 1 2026-08-14 16:11:07 +08:00
root 3194356859 1 2026-08-14 16:06:07 +08:00
root 978475f0cd 1 2026-08-14 15:59:47 +08:00
sjy 5be40ead52 对公转账信息 2026-08-14 15:55:41 +08:00
sjy 7fc58e62ce 修复对公转账 2026-08-14 15:54:21 +08:00
root 5eed7686c9 1 2026-08-14 15:52:52 +08:00
root 4a2355a902 1 2026-08-14 15:47:51 +08:00
root 7ab5c9a5c9 1 2026-08-14 15:42:12 +08:00
root 556ea4dbd2 1 2026-08-14 15:39:22 +08:00
root 049bbb0d24 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:36:22 +08:00
root bedf39d4f7 1 2026-08-14 15:35:54 +08:00
sjy abb479c469 Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:34:53 +08:00
GinHa 04fbcc8d8b Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:33:00 +08:00
GinHa c2971f828f 团队积分V2 2026-08-14 15:32:55 +08:00
root 45576e7d97 1 2026-08-14 15:30:29 +08:00
GinHa 1fb75676fc Merge branch 'main' of https://gitlab.minzhong.cn/mz/video-gen 2026-08-14 15:26:21 +08:00
GinHa fac54b5667 团队积分V1 2026-08-14 15:23:46 +08:00
root 1565eeebc5 1 2026-08-14 15:22:13 +08:00
root a754a9e004 1 2026-08-14 15:13:21 +08:00
root 289e458e7c 1 2026-08-14 14:49:35 +08:00
root 3f18b28227 1 2026-08-14 14:46:50 +08:00
root 7c3ef21762 增加银行流水和定时任务程序 2026-08-14 14:35:25 +08:00
root 148b89c3ca 1 2026-08-14 14:03:36 +08:00
root ae92f8b78c 1 2026-08-14 13:56:09 +08:00
sjy 4db879199c 保留修改 2026-08-14 13:29:00 +08:00
root 59116b93d8 123 2026-08-14 13:20:03 +08:00
root 07d476e9ac 1122 2026-08-14 12:51:24 +08:00
root 5afcfe1513 1 2026-08-14 12:16:10 +08:00
sjy 516106c045 对公转账 2026-08-13 17:59:08 +08:00
sjy 5e504eddaf Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 17:57:38 +08:00
sjy da3f2cac49 冲突 2026-08-13 17:56:58 +08:00
root a31b58316e 1 2026-08-13 16:39:10 +08:00
root 299dc549cd 1 2026-08-13 16:38:15 +08:00
root c091960b87 1 2026-08-13 16:21:40 +08:00
root ee380cfcc1 1 2026-08-13 16:20:55 +08:00
root 9b13968762 1 2026-08-13 16:10:43 +08:00
root 7dcedf974d 1、后台菜单增加定时任务和银行交易
2、增加后台配置银行账户和请求网址
2026-08-13 15:12:16 +08:00
root 33a5cc4f94 1 2026-08-13 11:54:26 +08:00
root b4b629a497 1 2026-08-13 11:50:50 +08:00
root 2aa737edaa 1 2026-08-13 11:34:25 +08:00
root fe855e0fa2 1 2026-08-13 11:31:27 +08:00
root 437be9dac0 “单设备登录(同端互斥)”功能和后台全局用户配置 2026-08-13 10:29:42 +08:00
sjy 780769ddee 冲突解决 2026-08-13 10:02:22 +08:00
sjy b917f19db5 会员充值修改 2026-08-13 09:56:11 +08:00
root 4754151cbb Merge branch 'main' of https://gitee.com/wg123/video-gen 2026-08-13 09:38:36 +08:00
root 58e061c9e7 前台登陆逻辑修改,请帮我实现“单设备登录(同端互斥)”功能 2026-08-13 09:37:39 +08:00
GinHa 6ed82b2e04 积分消耗明细改版V2 build app/admin 2026-08-12 11:53:22 +08:00
GinHa eb7c60684e 积分消耗明细改版V1 2026-08-12 11:49:53 +08:00
GinHa e3719eae09 会员积分改版V10 2026-08-11 15:38:53 +08:00
GinHa 45b9409fe8 会员积分改版V9 2026-08-11 14:27:21 +08:00
GinHa 81ef619ad3 会员积分改版V8 追加订单自动过期celery定时任务 2026-08-11 14:11:59 +08:00
GinHa a49ed802f4 会员积分改版V7 2026-08-11 13:23:22 +08:00
GinHa 809ff47120 会员积分改版V6 修复订单异步时区校验BUG 2026-08-11 11:57:55 +08:00
GinHa 890c1ea83e 会员积分改版V5 版本迁移合并 2026-08-11 10:30:43 +08:00
GinHa ba32d6f55e 会员积分改版V4 | 修复历史素材云兼容BUG | build app/admin 2026-08-11 10:27:06 +08:00
GinHa 392b9f4117 merge main 2026-08-11 10:16:38 +08:00
GinHa a1b82635c7 会员积分改版V3 2026-08-11 10:02:08 +08:00
GinHa 41b7cd395b 会员积分改版V2 2026-08-11 09:43:12 +08:00
GinHa b9fd07f293 会员积分改版V1 2026-08-11 09:24:18 +08:00
root f38f9b09ab 1 2026-08-11 09:24:16 +08:00
root 5bcc7888c2 1 2026-08-10 19:05:07 +08:00
root 0e47719d5c 1 2026-08-10 18:59:31 +08:00
root decabb5c42 1 2026-08-10 18:58:31 +08:00
root 80d0fd26f1 1 2026-08-10 18:47:34 +08:00
root 487c15779d 1 2026-08-10 18:45:26 +08:00
root 190cf54f0f 1 2026-08-10 18:39:29 +08:00
root baf97b5fc5 1 2026-08-10 18:31:45 +08:00
root 7237e8715f 1 2026-08-10 17:59:24 +08:00
root c1092f0deb 1 2026-08-10 17:38:47 +08:00
root e83f963534 1 2026-08-10 17:33:50 +08:00
root 9f7dc731b4 1 2026-08-10 17:23:59 +08:00
root 2bbcb75328 1 2026-08-10 16:53:13 +08:00
root 86c65f9ec8 1 2026-08-10 16:42:47 +08:00
root d1e8eb7316 1 2026-08-10 16:29:26 +08:00
root e364c46ce4 1 2026-08-10 16:11:04 +08:00
root a699c9bab8 1 2026-08-10 16:04:15 +08:00
root 7777454b16 1 2026-08-10 16:00:44 +08:00
root e8e590948c 1 2026-08-10 15:47:07 +08:00
root 6d30347cdb 1、增加订单开发票功能和发票抬头添加功能
2、一个订单只能在一个开票里,不允许多开
2026-08-10 15:39:00 +08:00
sjy e77d78db1e “同步sun分支(开票管理)” 2026-08-10 10:47:21 +08:00
sjy e2a2b6a16f Merge branch 'sun' 2026-08-10 10:42:09 +08:00
sjy e4fe42991d 完成sun分支功能 2026-08-10 10:39:01 +08:00
sjy 5d2c63ced1 ai创建替换缩略图 2026-08-03 13:47:39 +08:00
195 changed files with 26589 additions and 8660 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-CfFryG8z.js"></script>
<script type="module" crossorigin src="/assets/index-B0W8S7jH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
+12 -2
View File
@@ -21,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminCreditProducts from './pages/AdminCreditProducts';
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
@@ -42,6 +44,9 @@ import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
import AdminApiKeys from './pages/AdminApiKeys';
import AdminApiModelPricings from './pages/AdminApiModelPricings';
import AdminApiUsage from './pages/AdminApiUsage';
import AdminInvoices from './pages/AdminInvoices';
import AdminBankTransactions from './pages/AdminBankTransactions';
import AdminScheduledTasks from './pages/AdminScheduledTasks';
import { useAdminStore } from './store';
@@ -97,7 +102,9 @@ const App = () => {
<Route path="image-engines" element={<AdminImageEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="credit-products" element={<AdminCreditProducts />} />
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
@@ -106,6 +113,9 @@ const App = () => {
<Route path="api-keys" element={<AdminApiKeys />} />
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
<Route path="api-usage" element={<AdminApiUsage />} />
<Route path="invoices" element={<AdminInvoices />} />
<Route path="bank-transactions" element={<AdminBankTransactions />} />
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
+201 -27
View File
@@ -17,7 +17,8 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
BankAccount, ScheduledTask,
} from '../types';
import type {
@@ -208,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminTeamDetail(teamId: string): Promise<AdminTeam> { return api.get(`/admin/teams/${teamId}`); }
export async function getAdminTeamSubscriptions(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/subscriptions`); }
export async function getAdminTeamMemberUsage(teamId: string, subscriptionId?: string): Promise<any[]> {
const q = subscriptionId ? `?subscription_id=${encodeURIComponent(subscriptionId)}` : ''; return api.get(`/admin/teams/${teamId}/member-usage${q}`);
}
export async function getAdminTeamManagerHistory(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/manager-history`); }
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
@@ -216,6 +224,106 @@ export async function toggleUserStatus(userId: string, isActive: boolean): Promi
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
}
export async function queryBankTransactions(params: {
accountId: string;
startDate: string;
endDate: string;
dcFlag?: number;
page?: number;
pageSize?: number;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
qs.set('account_id', params.accountId);
qs.set('start_date', params.startDate);
qs.set('end_date', params.endDate);
if (params.dcFlag !== undefined) qs.set('dc_flag', String(params.dcFlag));
if (params.page) qs.set('page', String(params.page));
if (params.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/bank/transactions?${qs.toString()}`);
}
// ── Bank Account Management ────────────────────────────────
export async function listBankAccounts(): Promise<{ items: BankAccount[] }> {
return api.get('/admin/bank/accounts');
}
export async function createBankAccount(payload: {
account_name: string;
bank_name: string;
account_no: string;
is_active?: boolean;
is_default?: boolean;
description?: string;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/bank/accounts', payload);
}
export async function updateBankAccount(
accountId: string,
payload: Partial<{
account_name: string;
bank_name: string;
account_no: string;
is_active: boolean;
is_default: boolean;
description: string;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/bank/accounts/${accountId}`, payload);
}
export async function deleteBankAccount(accountId: string): Promise<{ message: string }> {
await api.delete(`/admin/bank/accounts/${accountId}`);
return { message: '删除成功' };
}
// ── Scheduled Tasks ────────────────────────────────────────
export async function listScheduledTasks(): Promise<{ items: ScheduledTask[] }> {
return api.get('/admin/scheduled-tasks');
}
export async function createScheduledTask(payload: {
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config?: Record<string, unknown> | string;
is_active?: boolean;
}): Promise<{ id: string; message: string }> {
return api.post('/admin/scheduled-tasks', payload);
}
export async function updateScheduledTask(
taskId: string,
payload: Partial<{
name: string;
task_type: 'external_api' | 'internal_method';
schedule: string;
config: Record<string, unknown> | string;
is_active: boolean;
}>,
): Promise<{ message: string }> {
return api.put(`/admin/scheduled-tasks/${taskId}`, payload);
}
export async function deleteScheduledTask(taskId: string): Promise<{ message: string }> {
await api.delete(`/admin/scheduled-tasks/${taskId}`);
return { message: '删除成功' };
}
export async function runScheduledTask(taskId: string): Promise<{ message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/run`, {});
}
export async function toggleScheduledTask(taskId: string): Promise<{ is_active: boolean; message: string }> {
return api.post(`/admin/scheduled-tasks/${taskId}/toggle`, {});
}
export async function updateSingleDeviceLoginOverride(userId: string, override: boolean | null): Promise<void> {
await api.put(`/admin/users/${userId}/single-device-login-override`, { override });
}
export async function getModelConfigs(): Promise<ModelConfig[]> {
return api.get('/admin/model-configs');
}
@@ -326,6 +434,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
@@ -531,41 +640,56 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
const q = new URLSearchParams();
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
if (params?.status) q.set('status', params.status); if (params?.startDate) q.set('start_date', params.startDate); if (params?.endDate) q.set('end_date', params.endDate);
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: string; orderSource?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method);
if (params?.method) qs.set('payment_method', params.method); if (params?.orderSource) qs.set('order_source', params.orderSource); if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone); if (params?.startDate) qs.set('start_date', params.startDate); if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
}
// 退款接口后端仍保留用于兼容旧调用,但当前版本固定返回“暂未开放订单退款”。
export async function refundPaymentOrder(orderNo: string): Promise<void> { await api.post(`/admin/payment-orders/${orderNo}/refund`); }
// ── Invoice Management ───────────────────────────────────
export async function getAdminInvoices(params?: {
page?: number;
pageSize?: number;
status?: string;
phone?: string;
startDate?: string;
endDate?: string;
}): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
return api.get(`/admin/invoices?${qs.toString()}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
export async function getAdminInvoiceDetail(id: string): Promise<any> {
return api.get(`/admin/invoices/${id}`);
}
export async function updateInvoiceStatus(id: string, data: {
status: 'success' | 'failed';
failureReason?: string;
}): Promise<void> {
await api.put(`/admin/invoices/${id}/status`, {
status: data.status,
failure_reason: data.failureReason,
});
}
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ total: number; items: any[] }> {
@@ -760,6 +884,8 @@ export async function getAdminGenerationRecords(params?: {
status?: string;
engineId?: string;
includeMediaReferences?: boolean;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}): Promise<{ total: number; items: any[] }> {
@@ -770,6 +896,8 @@ export async function getAdminGenerationRecords(params?: {
if (params?.includeMediaReferences !== undefined) {
q.set('include_media_references', String(params.includeMediaReferences));
}
if (params?.startDate) q.set('start_date', params.startDate);
if (params?.endDate) q.set('end_date', params.endDate);
if (params?.page) q.set('page', String(params.page));
if (params?.pageSize) q.set('page_size', String(params.pageSize));
const qs = q.toString();
@@ -1229,3 +1357,49 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
}
// ── Dynamic Credit Products ───────────────────────────────
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
return { ...raw, renewalEnabled: raw.renewalEnabled === true, tierRank: raw.tierRank == null ? null : Number(raw.tierRank), monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
firstPurchasePrice: Number(raw.firstPurchasePrice || 0), regularPrice: Number(raw.regularPrice || 0), activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
price: Number(raw.price || 0), grantCredits: Number(raw.grantCredits || 0), sortOrder: Number(raw.sortOrder || 0), isActive: raw.isActive === true, isDeleted: raw.isDeleted === true };
}
export async function getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).map(normalizeCreditProduct);
}
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload)); }
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload)); }
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/renewal`, { renewal_enabled: renewalEnabled })); }
export async function setCreditProductStatus(id: string, isActive: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/status`, { is_active: isActive })); }
export async function softDeleteCreditProduct(id: string): Promise<void> { await api.delete(`/admin/credit-management/products/${id}`); }
export async function getAdminUserCreditSummary(userId: string): Promise<any> { return api.get(`/admin/credit-management/users/${userId}/summary`); }
export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise<any[]> { const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); if (status) params.set('status', status); return api.get(`/admin/credit-management/users/${userId}/balances?${params.toString()}`); }
export async function getAdminUserSubscriptions(userId: string): Promise<any[]> { return api.get(`/admin/credit-management/users/${userId}/subscriptions`); }
export async function createAdminOfflineSubscription(userId: string, payload: { productId: string; quantity: number; paymentMethod: 'bank_transfer' | 'cash' | 'other'; actualPaidAmount?: number; offlineTradeNo?: string; offlinePaymentDetail?: string; remark?: string; }): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/offline-subscriptions`, { product_id: payload.productId, quantity: payload.quantity, payment_method: payload.paymentMethod, actual_paid_amount: payload.actualPaidAmount, offline_trade_no: payload.offlineTradeNo || null, offline_payment_detail: payload.offlinePaymentDetail || null, remark: payload.remark || null });
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/grant`, payload); }
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> { return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); }
// ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies');
}
export async function createLlmBillingPolicy(payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.post('/admin/llm-billing/policies', payload);
}
export async function updateLlmBillingPolicy(id: string, payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.put(`/admin/llm-billing/policies/${id}`, payload);
}
export async function getLlmBillingExecutions(params?: { page?: number; pageSize?: number; sceneCode?: string; status?: string; userId?: string }): Promise<{ items: import('../types').LlmBillingExecution[]; total: number }> {
const query = new URLSearchParams();
query.set('page', String(params?.page || 1));
query.set('page_size', String(params?.pageSize || 20));
if (params?.sceneCode) query.set('scene_code', params.sceneCode);
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('user_id', params.userId);
return api.get(`/admin/llm-billing/executions?${query.toString()}`);
}
@@ -0,0 +1,266 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
import dayjs from 'dayjs';
import { BankOutlined, SearchOutlined } from '@ant-design/icons';
import { queryBankTransactions, listBankAccounts } from '../api';
import { formatDate } from '../utils/formatDate';
import type { BankAccount } from '../types';
const { RangePicker } = DatePicker;
interface TransactionRecord {
id?: string;
accountId?: string;
accountNo?: string;
transactionNo?: string;
transactionTime?: string;
transactionAmount?: string;
balanceDirection?: string;
balanceAfter?: string;
counterpartyName?: string;
counterpartyAccount?: string;
counterpartyBank?: string;
remark?: string;
digestCode?: string;
syncBatch?: string;
isSynced?: boolean;
createdAt?: string;
}
const AdminBankTransactions: React.FC = () => {
const [data, setData] = useState<TransactionRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
// 银行账户
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
// 搜索条件
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
const [dcFlag, setDcFlag] = useState<number | undefined>(undefined);
useEffect(() => {
loadAccounts();
}, []);
const loadAccounts = async () => {
try {
const res = await listBankAccounts();
const items = res.items || [];
setAccounts(items);
// 默认选中默认账户(或第一个账户)
const defaultAccount = items.find(a => a.isDefault) || items[0];
if (defaultAccount) {
setSelectedAccountId(defaultAccount.id);
}
// 默认日期范围:今天
const today = new Date().toISOString().slice(0, 10);
setDateRange([today, today]);
} catch (err: any) {
message.error(err?.message || '加载银行账户失败');
}
};
const fetchData = useCallback(async () => {
if (!selectedAccountId || !dateRange[0] || !dateRange[1]) return;
setLoading(true);
try {
const res = await queryBankTransactions({
accountId: selectedAccountId,
startDate: dateRange[0],
endDate: dateRange[1],
dcFlag,
page,
pageSize,
});
setData(res.items || []);
setTotal(res.total || 0);
} catch (err: any) {
message.error(err?.message || '查询失败');
} finally {
setLoading(false);
}
}, [selectedAccountId, dateRange, dcFlag, page, pageSize]);
useEffect(() => {
if (selectedAccountId && dateRange[0] && dateRange[1]) {
fetchData();
}
}, [fetchData]);
const handleSearch = () => {
if (!selectedAccountId) {
message.warning('请选择银行账户');
return;
}
if (!dateRange[0] || !dateRange[1]) {
message.warning('请选择交易时间区间');
return;
}
setPage(1);
fetchData();
};
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
const columns = [
{
title: '交易流水号',
dataIndex: 'transactionNo',
width: 180,
render: (v: string) => v || '-',
},
{
title: '交易时间',
dataIndex: 'transactionTime',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
{
title: '交易金额',
dataIndex: 'transactionAmount',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '借贷方向',
dataIndex: 'balanceDirection',
width: 100,
render: (v: string) => v === 'DR' ? '借/出金' : v === 'CR' ? '贷/入金' : '-',
},
{
title: '交易后余额',
dataIndex: 'balanceAfter',
width: 120,
align: 'right' as const,
render: (v: string) => v != null ? Number(v).toFixed(2) : '-',
},
{
title: '对方户名',
dataIndex: 'counterpartyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: '对方账号',
dataIndex: 'counterpartyAccount',
width: 180,
render: (v: string) => v || '-',
},
{
title: '对方开户行',
dataIndex: 'counterpartyBank',
width: 150,
render: (v: string) => v || '-',
},
{
title: '摘要码',
dataIndex: 'digestCode',
width: 100,
render: (v: string) => v || '-',
},
{
title: '备注',
dataIndex: 'remark',
width: 150,
render: (v: string) => v || '-',
},
{
title: '同步时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => v ? formatDate(v) : '-',
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<BankOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Space wrap style={{ marginBottom: 16 }}>
<Select
placeholder="选择银行账户"
value={selectedAccountId}
onChange={setSelectedAccountId}
style={{ width: 260 }}
allowClear
options={accounts.map(a => ({
value: a.id,
label: `${a.accountNo} - ${a.accountName}(${a.bankName})`,
}))}
/>
<RangePicker
value={dateRange[0] && dateRange[1] ? [dayjs(dateRange[0]), dayjs(dateRange[1])] : undefined}
onChange={(dates, dateStrings) => {
if (Array.isArray(dateStrings)) {
setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
}
}}
/>
<Select
placeholder="借贷方向"
value={dcFlag}
onChange={setDcFlag}
style={{ width: 140 }}
allowClear
>
<Select.Option value={0}>/</Select.Option>
<Select.Option value={1}>/</Select.Option>
</Select>
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
{selectedAccount && (
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 8, fontSize: 13, color: '#64748b' }}>
<strong></strong>{selectedAccount.bankName} | {selectedAccount.accountNo} | {selectedAccount.accountName}
</div>
)}
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey={(r) => r.id || `${r.transactionNo || ''}-${Math.random()}`}
columns={columns}
dataSource={data}
loading={loading}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</Card>
</div>
);
};
export default AdminBankTransactions;
@@ -0,0 +1,469 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct,
getCreditProducts,
setCreditProductRenewal,
setCreditProductStatus,
softDeleteCreditProduct,
updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
const formatMoney = (value?: number | null): string => {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<CreditProduct | null>(null);
const [open, setOpen] = useState(false);
const [renewalEnabled, setRenewalEnabled] = useState(true);
const [form] = Form.useForm();
const replaceItem = (product: CreditProduct) => {
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
};
const load = async () => {
setLoading(true);
try {
setItems(await getCreditProducts(type));
} catch (error: any) {
message.error(error?.message || '加载积分商品失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [type]);
const openEditor = (item?: CreditProduct) => {
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
setEditing(item || null);
setRenewalEnabled(nextRenewalEnabled);
setOpen(true);
form.resetFields();
if (item) {
form.setFieldsValue({
productCode: item.productCode,
name: item.name,
description: item.description || undefined,
featuresText: (item.features || []).join('\n'),
tierCode: item.tierCode,
tierRank: item.tierRank,
billingCycle: item.billingCycle,
monthlyGrantCredits: item.monthlyGrantCredits,
firstPurchasePrice: item.firstPurchasePrice,
regularPrice: item.regularPrice,
activityPrice: item.activityPrice,
activityRange: item.activityStartAt && item.activityEndAt
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
: undefined,
renewalEnabled: nextRenewalEnabled,
price: item.price,
grantCredits: item.grantCredits,
validityMonths: item.validityMonths ?? 1,
sortOrder: item.sortOrder,
isActive: item.isActive,
});
return;
}
form.setFieldsValue({
productCode: undefined,
name: undefined,
description: undefined,
featuresText: undefined,
isActive: true,
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: true,
validityMonths: 1,
});
};
const save = async () => {
try {
const values = await form.validateFields();
const activityRange = values.activityRange || [];
const payload: Record<string, unknown> = {
name: values.name,
description: values.description || null,
features: String(values.featuresText || '')
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
sort_order: values.sortOrder ?? 0,
};
if (!editing) {
payload.product_code = values.productCode;
payload.product_type = type;
payload.is_active = values.isActive ?? true;
}
if (isSubscriptionType(type)) {
if (!editing) {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
});
}
Object.assign(payload, {
monthly_grant_credits: values.monthlyGrantCredits,
first_purchase_price: values.firstPurchasePrice,
regular_price: values.regularPrice,
activity_price: values.activityPrice ?? null,
activity_start_at: activityRange[0]?.toISOString() || null,
activity_end_at: activityRange[1]?.toISOString() || null,
renewal_enabled: renewalEnabled,
price: values.regularPrice,
});
} else {
Object.assign(payload, {
price: values.price,
grant_credits: values.grantCredits,
validity_months: values.validityMonths,
});
}
let savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (editing && savedProduct.isActive !== (values.isActive === true)) {
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
}
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
if (editing) {
replaceItem(savedProduct);
} else {
setItems((current) => [...current, savedProduct].sort((a, b) => {
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
}));
}
message.success(editing ? '商品已更新' : '商品已创建');
setOpen(false);
setEditing(null);
setRenewalEnabled(true);
form.resetFields();
} catch (error: any) {
if (error?.errorFields) return;
message.error(error?.message || '保存失败');
}
};
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
const previous = product.renewalEnabled === true;
const optimistic = { ...product, renewalEnabled: enabled };
replaceItem(optimistic);
if (editing?.id === product.id) {
setEditing(optimistic);
setRenewalEnabled(enabled);
form.setFieldValue('renewalEnabled', enabled);
}
try {
const savedProduct = await setCreditProductRenewal(product.id, enabled);
if (savedProduct.renewalEnabled !== enabled) {
throw new Error('续费开关保存结果与提交值不一致');
}
replaceItem(savedProduct);
if (editing?.id === product.id) {
setEditing(savedProduct);
setRenewalEnabled(savedProduct.renewalEnabled === true);
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
}
message.success(enabled ? '已开启续费' : '已关闭续费');
} catch (error: any) {
const reverted = { ...product, renewalEnabled: previous };
replaceItem(reverted);
if (editing?.id === product.id) {
setEditing(reverted);
setRenewalEnabled(previous);
form.setFieldValue('renewalEnabled', previous);
}
message.error(error?.message || '更新续费状态失败');
}
};
const toggleStatus = async (row: CreditProduct) => {
try {
const saved = await setCreditProductStatus(row.id, !row.isActive);
replaceItem(saved);
message.success(saved.isActive ? '已上架' : '已下架');
} catch (error: any) {
message.error(error?.message || '商品状态更新失败');
}
};
const removeProduct = async (row: CreditProduct) => {
try {
await softDeleteCreditProduct(row.id);
message.success('商品已软删除,商品编码永久保留且不能恢复');
await load();
} catch (error: any) {
message.error(error?.message || '商品删除失败');
}
};
const columns = useMemo(() => isSubscriptionType(type) ? [
{
title: '套餐',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{
title: '等级',
key: 'tier',
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
},
{
title: '周期',
dataIndex: 'billingCycle',
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
},
{
title: '每月积分',
dataIndex: 'monthlyGrantCredits',
render: (value?: number) => Number(value || 0).toLocaleString(),
},
{
title: '首充价',
dataIndex: 'firstPurchasePrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '原价',
dataIndex: 'regularPrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '活动价',
key: 'activity',
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
? '-'
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
},
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary">-</Typography.Text>
: <Switch
checked={enabled === true}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => void toggleRenewal(row, checked)}
/>,
},
{
title: '状态',
key: 'status',
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
title: '增值包',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items, editing]);
const createButtonLabel = type === 'subscription'
? '新增个人订阅套餐'
: type === 'team_subscription'
? '新增团队订阅套餐'
: '新增积分增值包';
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '个人订阅套餐' },
{ key: 'team_subscription', label: '团队订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
<Modal
open={open}
title={editing ? '编辑积分商品' : '新增积分商品'}
onOk={save}
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
width={760}
destroyOnClose
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item
name="productCode"
label="商品编码"
rules={[{ required: true }]}
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input style={{ width: 260 }} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{isSubscriptionType(type) ? <>
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
disabled={!!editing}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
{ value: 'quarterly', label: '季' },
{ value: 'yearly', label: '年' },
]}
/>
</Form.Item>
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16} wrap>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
>
<Switch
checked={renewalEnabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => {
setRenewalEnabled(checked);
form.setFieldValue('renewalEnabled', checked);
}}
/>
</Form.Item>
</Space>
</> : <Space align="start" size={16}>
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
<Select
style={{ width: 140 }}
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
/>
</Form.Item>
</Space>}
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminCreditProducts;
@@ -3,12 +3,11 @@ import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
deleteCreditRatio, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
@@ -54,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, sysConfigs, enginesData] = await Promise.all([
const [ratioData, enginesData] = await Promise.all([
getCreditRatios(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
@@ -81,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
setRatios(ratioData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
setTextRateConfig({ id: textCfg.id });
}
} catch {
message.error('加载积分比例失败');
} finally {
@@ -171,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
}
};
const handleSaveTextRate = async () => {
if (!textRateConfig) return;
setSavingTextRate(true);
try {
await updateSystemConfig(textRateConfig.id, String(textRate));
message.success('文字积分费率已更新');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSavingTextRate(false);
}
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
@@ -338,40 +316,6 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
ceil(token数 x / 1000)1
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<Space.Compact style={{ width: 160 }}>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: '100%' }}
/>
<Typography.Text></Typography.Text>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1000 token = {textRate} 500 token = {(500 * textRate / 1000).toFixed(4)}
</Typography.Text>
</div>
</Card>
{/* Generation Credit Ratios */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
+151 -42
View File
@@ -8,7 +8,7 @@ import {
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -40,15 +40,18 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
};
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实费', color: 'red' },
charge: { text: '真实费', color: 'red' },
refund: { text: '真实退款', color: 'blue' },
hold: { text: '预扣占用', color: 'gold' },
hold_release: { text: '预扣释放', color: 'green' },
pre_deduct: { text: '历史固定预扣', color: 'default' },
hold: { text: '历史预扣占用', color: 'gold' },
hold_release: { text: '历史预扣释放', color: 'green' },
};
const userScopeOptions = [
@@ -63,6 +66,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'expire', label: '过期' },
{ value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -101,10 +106,11 @@ const chargeKindOptions = [
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实费' },
{ value: 'charge', label: '真实费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
{ value: 'pre_deduct', label: '历史固定预扣' },
{ value: 'hold', label: '历史预扣占用' },
{ value: 'hold_release', label: '历史预扣释放' },
];
const sourceModuleOptions = [
@@ -176,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
return {};
}
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
const label = item.tierLabel || item.tierCode || '未知等级';
const code = item.tierCode && item.tierCode !== label ? `${item.tierCode}` : '';
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
return `${label}${code}${rank}`;
}
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
return [
item.productTypeLabel,
item.productName,
subscriptionTierText(item),
item.billingCycleLabel,
].filter(Boolean).join(' · ');
}
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
const range = item.periodValidFrom && item.periodExpiresAt
? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}`
: '-';
return `${item.periodLabel || '周期未知'}${range}`;
}
function semanticAllocationText(record: AdminCreditRecord): string {
return (record.allocations || []).map((item) => {
const tier = item.tierLabel || item.tierCode
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `${item.tierCode}` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
: '未知等级';
const subscription = item.subscriptionNo
? `${item.subscriptionNo}${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
: '非订阅资金';
const period = item.periodLabel
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}${formatDate(item.periodExpiresAt)}` : ''}`
: '无月度周期';
return `${item.creditScopeLabel || '其他资金域'}${item.allocationActionLabel || '资金变动'}${subscription}${period}${n(item.amount)}积分`;
}).join('');
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
@@ -188,6 +232,7 @@ const AdminCreditRecords: React.FC = () => {
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [recordType, setRecordType] = useState('');
const [creditSubject, setCreditSubject] = useState('');
@@ -205,6 +250,7 @@ const AdminCreditRecords: React.FC = () => {
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
subscriptionNo: subscriptionNoFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
@@ -216,7 +262,7 @@ const AdminCreditRecords: React.FC = () => {
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
const load = async () => {
setLoading(true);
@@ -241,6 +287,7 @@ const AdminCreditRecords: React.FC = () => {
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setSubscriptionNoFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
@@ -279,16 +326,24 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join('') || '-' },
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionProductText(item)}`).join('') || '-' },
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionPeriodText(item)}`).join('') || '-' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.sceneNameSnapshot || r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0}` },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
@@ -300,6 +355,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -310,27 +366,24 @@ const AdminCreditRecords: React.FC = () => {
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['订阅实例筛选', subscriptionNoFilter || '不限'],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge],
[' · 预扣占用(独立统计:type=消费 & action=hold', exportSummary.totalHold],
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
[' · 真实退款(独立统计:type=回退 & action=refund/NULL', exportSummary.totalRefundReal],
[' · 预扣释放(独立统计:type=回退 & action=hold_release', exportSummary.totalHoldRelease],
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
['总真实消费', exportSummary.totalConsume],
['总真实退款', exportSummary.totalRefund],
['净消耗(总消费 总退款,≥ 0', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分(仅真实费)', exportSummary.imageConsume],
['视频消费积分(仅真实费)', exportSummary.videoConsume],
['提词消费积分(仅真实费)', exportSummary.textConsume],
['视频分析积分(仅真实费)', exportSummary.analysisConsume],
['图片消费积分(仅真实费)', exportSummary.imageConsume],
['视频消费积分(仅真实费)', exportSummary.videoConsume],
['提词消费积分(仅真实费)', exportSummary.textConsume],
['视频分析积分(仅真实费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
@@ -351,14 +404,55 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{
title: '资金构成', key: 'fundingScope', width: 190,
render: (_: any, r: AdminCreditRecord) => (
<Space size={[4, 4]} wrap>
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue"> {n(r.teamAllocationAmount)}</Tag>}
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple"> {n(r.personalAllocationAmount)}</Tag>}
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary"></Typography.Text>}
</Space>
),
},
{
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · {n(item.amount)} </div>
</div>
))}
</div>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '月度周期', key: 'subscriptionPeriods', width: 300,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b' }}>
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}` : '-'}
</div>
</div>
))}
</div>
) : '-',
},
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.sceneNameSnapshot || r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} <div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.llmSuccessCallCount || 0)} / {n(r.llmFailedCallCount || 0)}</div></div> },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
@@ -380,32 +474,18 @@ const AdminCreditRecords: React.FC = () => {
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}> + </span>
</div>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
<span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}></div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>
退
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>退 + </span>
</div>
<div style={{ color: '#94a3b8' }}>退</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
退 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>退</div>
</div>
</Space>
</Card>
@@ -414,7 +494,7 @@ const AdminCreditRecords: React.FC = () => {
<div>
<div style={{ color: '#6366f1' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div>
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div>
</div>
</Space>
</Card>
@@ -449,6 +529,7 @@ const AdminCreditRecords: React.FC = () => {
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input placeholder="订阅实例号(PS/TS" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
@@ -480,7 +561,35 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2160 }}
expandable={{
rowExpandable: (record) => Boolean(record.allocations?.length),
expandedRowRender: (record) => (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `${a.tierCode}` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ${formatDate(a.periodExpiresAt)}` : '-' },
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ${formatDate(a.expiresAt)}` : '-' },
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
]}
scroll={{ x: 2390 }}
/>
),
}}
scroll={{ x: 3350 }}
/>
</Card>
</div>
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Card,
DatePicker,
Empty,
Input,
message,
@@ -24,6 +25,7 @@ import {
VideoCameraOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -178,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
</div>
);
const todayStart = () => dayjs().startOf('day');
const todayEnd = () => dayjs().endOf('day');
const AdminGenerationRecords: React.FC = () => {
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
const [total, setTotal] = useState(0);
@@ -195,6 +200,10 @@ const AdminGenerationRecords: React.FC = () => {
const [videoPlaying, setVideoPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
// 时间筛选(默认当天)
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
const load = useCallback(async () => {
setLoading(true);
try {
@@ -203,6 +212,8 @@ const AdminGenerationRecords: React.FC = () => {
status: filterStatus || undefined,
engineId: filterEngineId || undefined,
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
page,
pageSize,
});
@@ -246,7 +257,7 @@ const AdminGenerationRecords: React.FC = () => {
} finally {
setLoading(false);
}
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize]);
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
useEffect(() => {
load();
@@ -300,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
}, [preview]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
setReloadKey((v) => v + 1);
};
const handleReset = () => {
setFilterStatus('');
setFilterUserId('');
setFilterEngineId('');
setFilterIncludeMedia('');
const defaultRange = [todayStart(), todayEnd()];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
setReloadKey((v) => v + 1);
};
@@ -870,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
onPressEnter={handleSearch}
allowClear
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
</Button>
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
</Button>
</Space>
</div>
+435
View File
@@ -0,0 +1,435 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
import { formatDate } from '../utils/formatDate';
import type { InvoiceItem, InvoiceDetail } from '../types';
const AdminInvoices: React.FC = () => {
const [data, setData] = useState<InvoiceItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [phoneFilter, setPhoneFilter] = useState<string>('');
const [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
// 详情弹窗
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
// 失败原因弹窗
const [failModalOpen, setFailModalOpen] = useState(false);
const [failReason, setFailReason] = useState('');
const [failTargetId, setFailTargetId] = useState<string | null>(null);
const [failSubmitting, setFailSubmitting] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const res = await getAdminInvoices({
page,
pageSize,
status: statusFilter || undefined,
phone: phoneFilter || undefined,
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
});
setData(res.items);
setTotal(res.total);
} catch (err: any) {
message.error(err?.message || '获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [page, pageSize, statusFilter, queryCreatedRange]);
const handleSearch = () => {
setPage(1);
setQueryCreatedRange(createdRange);
};
const handleReset = () => {
setStatusFilter(null);
setPhoneFilter('');
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
setCreatedRange(defaultRange);
setQueryCreatedRange(defaultRange);
setPage(1);
};
const handleViewDetail = async (id: string) => {
setDetailLoading(true);
setDetailModalOpen(true);
try {
const detail = await getAdminInvoiceDetail(id);
setCurrentDetail(detail);
} catch (err: any) {
message.error(err?.message || '获取详情失败');
setDetailModalOpen(false);
} finally {
setDetailLoading(false);
}
};
const handleMarkSuccess = async (id: string) => {
try {
await updateInvoiceStatus(id, { status: 'success' });
message.success('已标记为开具成功');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const handleOpenFailModal = (id: string) => {
setFailTargetId(id);
setFailReason('');
setFailModalOpen(true);
};
const handleConfirmFail = async () => {
if (!failReason.trim()) {
message.warning('请填写失败原因');
return;
}
if (!failTargetId) return;
setFailSubmitting(true);
try {
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
message.success('已标记为开具失败');
setFailModalOpen(false);
setFailTargetId(null);
setFailReason('');
fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
setFailSubmitting(false);
};
const handlePageChange = (p: number, ps: number) => {
setPage(p);
setPageSize(ps);
};
const statusTag = (status: string) => {
const config: Record<string, { color: string; label: string }> = {
processing: { color: 'blue', label: '开具中' },
success: { color: 'green', label: '已开具' },
failed: { color: 'red', label: '已失败' },
};
const c = config[status] || { color: 'default', label: status };
return <Tag color={c.color}>{c.label}</Tag>;
};
const columns = [
{
title: '发票编号',
dataIndex: 'invoiceNo',
key: 'invoiceNo',
width: 180,
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
},
{
title: '用户',
dataIndex: 'username',
key: 'username',
width: 100,
render: (_: string, record: InvoiceItem) => (
<div>
<div><Typography.Text strong>{record.username}</Typography.Text></div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
</div>
),
},
{
title: '抬头类型',
dataIndex: 'headerType',
key: 'headerType',
width: 80,
render: (v: string) => (v === 'company' ? '企业' : '个人'),
},
{
title: '抬头名称',
dataIndex: 'headerName',
key: 'headerName',
width: 160,
ellipsis: true,
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 160,
ellipsis: true,
},
{
title: '总金额',
dataIndex: 'totalAmount',
key: 'totalAmount',
width: 100,
align: 'right' as const,
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
},
{
title: '订单数',
dataIndex: 'orderCount',
key: 'orderCount',
width: 70,
align: 'center' as const,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
render: (v: string) => statusTag(v),
},
{
title: '创建时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
render: (v: string) => formatDate(v),
},
{
title: '操作',
key: 'actions',
width: 260,
render: (_: unknown, record: InvoiceItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
</Button>
{record.status === 'processing' && (
<>
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
</Button>
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
</Button>
</>
)}
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple"> {total} </Tag>
</Space>
<div style={{ display: 'flex', gap: 8 }}>
<Button
type={statusFilter === null ? 'primary' : 'default'}
onClick={() => { setStatusFilter(null); setPage(1); }}
icon={<FilterOutlined />}
size="small"
>
</Button>
<Button
type={statusFilter === 'processing' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('processing'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'success' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('success'); setPage(1); }}
size="small"
>
</Button>
<Button
type={statusFilter === 'failed' ? 'primary' : 'default'}
onClick={() => { setStatusFilter('failed'); setPage(1); }}
size="small"
>
</Button>
</div>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Input
placeholder="按用户手机号搜索"
value={phoneFilter}
onChange={(e) => setPhoneFilter(e.target.value)}
style={{ width: 200 }}
allowClear
onPressEnter={handleSearch}
/>
<DatePicker.RangePicker
value={createdRange}
onChange={(dates) => {
if (dates && dates[0] && dates[1]) {
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
} else {
setCreatedRange(dates);
}
}}
placeholder={['开始日期', '结束日期']}
/>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : data.length === 0 ? (
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
) : (
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 1400 }}
/>
)}
</Card>
{/* 详情弹窗 */}
<Modal
title={<Space><EyeOutlined /></Space>}
open={detailModalOpen}
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
footer={null}
width={700}
>
{detailLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>...</div>
) : currentDetail && (
<div style={{ padding: 8 }}>
{/* 基本信息 */}
<div style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
{currentDetail.invoiceNo}
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
</Typography.Title>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
<Typography.Text style={{ color: '#64748b' }}>ID</Typography.Text>
<Typography.Text>{currentDetail.userId}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerName}</Typography.Text>
{currentDetail.headerTaxNo && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
</>
)}
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{currentDetail.email}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
{currentDetail.issuedAt && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
</>
)}
{currentDetail.failureReason && (
<>
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
</>
)}
</div>
</div>
{/* 关联订单 */}
{currentDetail.orders && currentDetail.orders.length > 0 && (
<div>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
{currentDetail.orders.length}
</Typography.Text>
<Table
dataSource={currentDetail.orders}
columns={[
{
title: '订单号',
dataIndex: 'orderNo',
key: 'orderNo',
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
align: 'right' as const,
render: (v: number) => `¥${v.toFixed(2)}`,
},
{
title: '积分',
dataIndex: 'credits',
key: 'credits',
align: 'right' as const,
},
]}
rowKey="id"
pagination={false}
size="small"
/>
</div>
)}
</div>
)}
</Modal>
{/* 失败原因弹窗 */}
<Modal
title="开具失败"
open={failModalOpen}
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
onOk={handleConfirmFail}
okText="确认"
cancelText="取消"
confirmLoading={failSubmitting}
>
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
</Typography.Text>
<Input.TextArea
value={failReason}
onChange={(e) => setFailReason(e.target.value)}
placeholder="例如:抬头信息有误,请重新提交"
rows={3}
maxLength={500}
showCount
/>
</Modal>
</div>
);
};
export default AdminInvoices;
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { getLlmBillingExecutions } from '../api';
import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
charged: { label: '已消费', color: 'blue' },
processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' },
refunded: { label: '已退款', color: 'purple' },
refund_failed: { label: '退款失败', color: 'red' },
};
const AdminLlmBillingExecutions: React.FC = () => {
const [items, setItems] = useState<LlmBillingExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>();
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
useEffect(() => { load(); }, [page, status]);
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
<Timeline items={(record.calls || []).map((call) => ({
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
children: <div>
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)}  {call.callSequence} {call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
<div>{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
<div>{call.modelNameSnapshot || '-'} {call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
{call.tokenUnavailableReason
? <div>Token{call.tokenUnavailableReason}</div>
: <div> Token{call.inputTokens ?? 0}  Token{call.outputTokens ?? 0}  Token{call.totalTokens ?? 0}</div>}
{call.errorMessage && <Typography.Text type="danger">{call.errorMessage}</Typography.Text>}
{call.postprocessStatus === 'succeeded' && <div></div>}
{call.postprocessError && <div>{call.postprocessError}</div>}
</div>,
}))} />
<Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '消费积分', children: record.chargeCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
]} />
</div> }} columns={[
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
{ title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
]} />
</Card>;
};
export default AdminLlmBillingExecutions;
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
import type { LlmBillingPolicy } from '../types';
const SCENES: Array<{ value: string; label: string }> = [
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
];
const AdminLlmBillingPolicies: React.FC = () => {
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
setItems(await getLlmBillingPolicies());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => {
setEditing(item || null);
setOpen(true);
form.resetFields();
form.setFieldsValue(item
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
: { is_active: true, charge_credits: 5 });
};
const save = async () => {
try {
const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values);
else await createLlmBillingPolicy(values);
message.success('已保存');
setOpen(false);
load();
} catch (error: any) {
if (!error?.errorFields) message.error(error?.message || '保存失败');
}
};
return <Card
title={<Space><RobotOutlined />LLM积分配置</Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}
>
<Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> },
]} />
<Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
<Select disabled={!!editing} options={SCENES} />
</Form.Item>
<Form.Item name="charge_credits" label="消费积分" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminLlmBillingPolicies;
+2 -4
View File
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
return <Tag color="blue">{labelMap[v] || v}</Tag>;
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
+61 -311
View File
@@ -1,324 +1,74 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminPaymentOrders, getPaymentStats } from '../api';
const { Option } = Select;
const { RangePicker } = DatePicker;
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(10);
const [orderTotal, setOrderTotal] = useState(0);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const [stats, setStats] = useState<any>(null);
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
const load = async () => {
try {
setLoading(true);
const [statsData, ordersData] = await Promise.all([
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
]);
setStats({ ...statsData, recent: ordersData.items || [] });
setOrderTotal(ordersData.total);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
const load = useCallback(async () => {
setLoading(true);
try {
const [s, o] = await Promise.all([
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
]);
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
} finally { setLoading(false); }
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
];
const handleOrderPageChange = (p: number, ps: number) => {
setOrderPage(p);
setOrderPageSize(ps);
};
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
const today = stats?.today || {};
const month = stats?.month || {};
const bySource = stats?.bySource || {};
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={8} md={5}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Input
placeholder="搜索手机号"
allowClear
value={filters.phone}
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
onPressEnter={() => { setOrderPage(1); load(); }}
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
/>
</Col>
<Col xs={24} sm={12} md={7}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<RangePicker
style={{ width: '100%' }}
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent || []}
rowKey="id"
loading={loading}
pagination={{
current: orderPage,
pageSize: orderPageSize,
total: orderTotal,
onChange: handleOrderPageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
return <ConfigProvider locale={zhCN}><div>
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(today.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(today.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(month.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(month.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} </Typography.Text></Card></Col>
</Row>
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} </div></div></Col>)}</Row></Card>
<Card title="订单列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={reset}></Button>
</Space>
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`${t}`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
</Card>
</div></ConfigProvider>;
};
export default AdminPaymentStats;
@@ -1,253 +1 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
interface PackageItem {
id: string;
name: string;
credits: number;
price: number;
bonusCredits: number;
totalCredits: number;
description: string | null;
packageType: string;
isGift: boolean;
isActive: boolean;
sortOrder: number;
}
const TYPE_COLORS: Record<string, string> = {
normal: 'blue',
gift: 'green',
promo: 'purple',
};
const TYPE_LABELS: Record<string, string> = {
normal: '常规',
gift: '赠送',
promo: '促销',
};
const AdminRechargePackages: React.FC = () => {
const [packages, setPackages] = useState<PackageItem[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getRechargePackages();
setPackages(data.map((item: any) => ({
id: item.id,
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
description: item.description,
packageType: item.package_type ?? item.packageType ?? 'normal',
isGift: item.is_gift ?? item.isGift ?? false,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
})));
} catch {
message.error('加载充值套餐失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
credits: values.credits,
price: values.price,
bonus_credits: values.bonusCredits || 0,
description: values.description || null,
package_type: values.packageType || 'normal',
is_gift: values.isGift || false,
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (modal.item?.id) {
await saveRechargePackage({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveRechargePackage(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
try {
await deleteRechargePackage(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: PackageItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonusCredits,
description: item.description,
packageType: item.packageType,
isGift: item.isGift,
isActive: item.isActive,
sortOrder: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
}
};
const columns = [
{
title: '套餐名称', key: 'name', width: 160,
render: (_: any, r: PackageItem) => (
<div>
<Typography.Text strong>{r.name}</Typography.Text>
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
</div>
),
},
{
title: '基础积分', dataIndex: 'credits', width: 100,
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
},
{
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
render: (v: number) => v > 0
? <Tag color="green">+{v.toLocaleString()}</Tag>
: <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '总积分', key: 'total', width: 100,
render: (_: any, r: PackageItem) => (
<Typography.Text strong style={{ color: '#6366f1' }}>
{(r.credits + r.bonusCredits).toLocaleString()}
</Typography.Text>
),
},
{
title: '价格(元)', dataIndex: 'price', width: 100,
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'packageType', width: 80,
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: PackageItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{packages.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={packages}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
<Input placeholder="例如:进阶包" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 'normal', label: '常规' },
{ value: 'gift', label: '赠送' },
{ value: 'promo', label: '促销' },
]} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input placeholder="套餐描述(可选)" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminRechargePackages;
export { default } from './AdminCreditProducts';
@@ -0,0 +1,300 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
} from 'antd';
import {
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons';
import {
listScheduledTasks,
createScheduledTask,
updateScheduledTask,
deleteScheduledTask,
runScheduledTask,
toggleScheduledTask,
} from '../api';
import type { ScheduledTask } from '../types';
const { TextArea } = Input;
const SCHEDULE_TYPE_OPTIONS = [
{ value: 'internal_method', label: '内部方法执行' },
];
const TASK_TYPE_LABELS: Record<string, string> = {
internal_method: '内部方法',
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
success: { label: '成功', color: 'green' },
error: { label: '失败', color: 'red' },
};
const AdminScheduledTasks: React.FC = () => {
const [data, setData] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState(false);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listScheduledTasks();
setData(res.items || []);
} catch (err: any) {
message.error(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const openModal = (task?: ScheduledTask) => {
if (task) {
setEditing(task);
let configStr = '';
if (task.config) {
try {
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
} catch {
configStr = task.config;
}
}
form.setFieldsValue({
name: task.name,
task_type: task.taskType,
schedule: task.schedule,
config: configStr,
is_active: task.isActive,
});
} else {
setEditing(null);
form.resetFields();
form.setFieldsValue({ is_active: true, task_type: 'internal_method' });
}
setActiveTab('basic');
setModal(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
let configVal = values.config;
if (configVal && typeof configVal === 'string') {
try {
configVal = JSON.stringify(JSON.parse(configVal));
} catch {
message.warning('配置 JSON 格式不合法,将按原样保存');
}
}
setSaving(true);
if (editing) {
await updateScheduledTask(editing.id, { ...values, config: configVal });
message.success('更新成功');
} else {
await createScheduledTask({ ...values, config: configVal });
message.success('创建成功');
}
setModal(false);
await fetchData();
} catch (err: any) {
message.error(err?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (taskId: string) => {
try {
await deleteScheduledTask(taskId);
message.success('删除成功');
await fetchData();
} catch (err: any) {
message.error(err?.message || '删除失败');
}
};
const handleRun = async (taskId: string) => {
try {
await runScheduledTask(taskId);
message.success('任务已提交执行');
setTimeout(fetchData, 1500);
} catch (err: any) {
message.error(err?.message || '执行失败');
}
};
const handleToggle = async (task: ScheduledTask) => {
try {
const res = await toggleScheduledTask(task.id);
message.success(res.message);
await fetchData();
} catch (err: any) {
message.error(err?.message || '操作失败');
}
};
const columns = [
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
{
title: '类型',
dataIndex: 'taskType',
width: 100,
render: (v: string) => <Tag color="purple">{TASK_TYPE_LABELS[v] || v}</Tag>,
},
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '最后执行',
dataIndex: 'lastRunAt',
width: 160,
render: (v: string, r: ScheduledTask) => {
if (!v) return '-';
const status = r.lastStatus ? STATUS_LABELS[r.lastStatus] : null;
return (
<span>
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
</span>
);
},
},
{
title: '操作',
width: 220,
render: (_: any, record: ScheduledTask) => (
<Space size="small">
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}></Button>
<Button type="link" size="small" icon={record.isActive ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
{record.isActive ? '禁用' : '启用'}
</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}></Button>
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const scheduleHelp = (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
<div> 3600 = </div>
<div> Cron 5 </div>
<div> <code>0 * * * *</code> = | <code>0 2 * * *</code> = 2</div>
</div>
);
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<ClockCircleOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
/>
</Card>
{/* 新增/编辑弹窗 */}
<Modal
title={editing ? '编辑定时任务' : '新增定时任务'}
open={modal}
onOk={handleSave}
onCancel={() => setModal(false)}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
width={600}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Tabs
activeKey={activeTab}
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
items={[
{
key: 'basic',
label: '基本设置',
children: (
<>
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
<Input placeholder="例如:每小时同步银行交易" />
</Form.Item>
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
<Select options={SCHEDULE_TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="schedule"
label="调度表达式"
rules={[{ required: true, message: '请输入调度表达式' }]}
extra={scheduleHelp}
>
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
</>
),
},
{
key: 'config',
label: '任务配置',
children: (
<Form.Item
name="config"
label="配置 JSON"
extra='module 和 function 指定调用的函数,其余字段作为参数传入。服务会自动翻页拉取全部流水:'
>
<TextArea
rows={10}
placeholder={'{\n "module": "app.services.bank.sync_service",\n "function": "sync_bank_transactions",\n "url": "http://ceshi.web.minzhong.cn/api/api/v1/internal/get-blank-transfer-acct-time",\n "api_key": "jixekCxm8piLFi0AlfA24bDtCKJ82bfu",\n "acct_no": "110972289710001",\n "start_date": "2026-08-01",\n "end_date": "2026-08-14"\n}'}
style={{ fontFamily: 'monospace', fontSize: 13 }}
/>
</Form.Item>
),
},
]}
/>
</Form>
</Modal>
</div>
);
};
export default AdminScheduledTasks;
+230 -111
View File
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
Button, Card, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Tabs, Typography, Upload, Table, Tag, Popconfirm,
} from 'antd';
import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined, BankOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
createSystemConfig,
@@ -14,8 +14,12 @@ import {
uploadLogo,
uploadPdf,
uploadLoginVideo,
listBankAccounts,
createBankAccount,
updateBankAccount,
deleteBankAccount,
} from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types';
import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
{ value: 'MB', label: 'MB1024 × 1024 字节)' },
@@ -30,10 +34,116 @@ const AdminSettings: React.FC = () => {
const [uploading, setUploading] = useState('');
const [form] = Form.useForm();
// 银行账户管理
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [loadingAccounts, setLoadingAccounts] = useState(false);
const [accountModal, setAccountModal] = useState(false);
const [accountEditing, setAccountEditing] = useState<BankAccount | null>(null);
const [accountForm] = Form.useForm();
const [accountSaving, setAccountSaving] = useState(false);
useEffect(() => {
load();
loadBankAccounts();
}, []);
const loadBankAccounts = async () => {
setLoadingAccounts(true);
try {
const res = await listBankAccounts();
setBankAccounts(res.items || []);
} catch (e: any) {
message.error(e?.message || '加载银行账户失败');
} finally {
setLoadingAccounts(false);
}
};
const openAccountModal = (account?: BankAccount) => {
if (account) {
setAccountEditing(account);
accountForm.setFieldsValue({
account_name: account.accountName,
bank_name: account.bankName,
account_no: account.accountNo,
is_active: account.isActive,
is_default: account.isDefault,
description: account.description,
});
} else {
setAccountEditing(null);
accountForm.resetFields();
accountForm.setFieldsValue({ is_active: true, is_default: false });
}
setAccountModal(true);
};
const handleAccountSave = async () => {
try {
const values = await accountForm.validateFields();
setAccountSaving(true);
if (accountEditing) {
await updateBankAccount(accountEditing.id, values);
message.success('更新成功');
} else {
await createBankAccount(values);
message.success('创建成功');
}
setAccountModal(false);
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setAccountSaving(false);
}
};
const handleAccountDelete = async (accountId: string) => {
try {
await deleteBankAccount(accountId);
message.success('删除成功');
await loadBankAccounts();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const accountColumns = [
{ title: '账户名称', dataIndex: 'accountName', width: 150 },
{ title: '开户银行', dataIndex: 'bankName', width: 150 },
{
title: '银行账号',
dataIndex: 'accountNo',
width: 180,
render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code>,
},
{
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '默认',
dataIndex: 'isDefault',
width: 80,
render: (v: boolean) => v && <Tag color="blue"></Tag>,
},
{ title: '备注', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作',
width: 140,
render: (_: any, record: BankAccount) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openAccountModal(record)}></Button>
<Popconfirm title="确定删除该账户?" onConfirm={() => handleAccountDelete(record.id)} okText="确定" cancelText="取消">
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
const load = async () => {
setLoading(true);
try {
@@ -45,17 +155,13 @@ const AdminSettings: React.FC = () => {
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
// 确保 single_device_login_enabled 配置存在
if (!data.some(c => c.key === 'single_device_login_enabled')) {
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
}
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// LLM 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -70,83 +176,14 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
? !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled).trim().toLowerCase(),
)
: false;
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true);
const llmManagedKeys = new Set([
'optimize_hold_credits',
'llm_billing_enabled',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
]);
for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
if (value === undefined || value === null || value === '') return;
const normalizedValue = String(value);
const existing = configs.find(c => c.key === key);
if (existing) {
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
} else {
await createSystemConfig(key, normalizedValue, description);
}
};
const enabledConfig = [
'llm_billing_enabled',
values.llm_billing_enabled,
'是否启用 LLM 统一预扣与真实扣费结算',
] as const;
const llmHoldConfigs = [
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
] as const;
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
if (!llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
for (const [key, value, description] of llmHoldConfigs) {
await saveManagedConfig(key, value, description);
}
if (llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -231,6 +268,26 @@ const AdminSettings: React.FC = () => {
message.success('已移除登录背景视频');
};
const handleToggleSingleDevice = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'single_device_login_enabled');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'single_device_login_enabled');
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}单设备登录限制`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
@@ -256,8 +313,8 @@ const AdminSettings: React.FC = () => {
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'收款银行': [],
'其他配置': configs.filter(c => c.key === 'operation_manual'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -273,13 +330,6 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
@@ -409,7 +459,7 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
@@ -470,30 +520,38 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
</Form.Item>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
>
<Input type="number" min={0} placeholder="默认5" size="large" />
</Form.Item>
{[
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
].map(([name, label, extra]) => (
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'integration',
label: '收款银行',
children: (
<div>
{/* 银行账户管理 */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
<Typography.Text strong style={{ fontSize: 14 }}>
<BankOutlined style={{ marginRight: 8, color: '#6366f1' }} />
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => openAccountModal()}>
</Button>
</div>
<Table
rowKey="id"
dataSource={bankAccounts}
loading={loadingAccounts}
pagination={false}
size="middle"
columns={accountColumns}
/>
</div>
</div>
),
},
{
key: 'other',
label: '其他配置',
@@ -598,13 +656,39 @@ const AdminSettings: React.FC = () => {
</div>
</div>
</div>
{/* 单设备登录限制 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
onChange={handleToggleSingleDevice}
checkedChildren="已启用"
unCheckedChildren="已禁用"
/>
</div>
</div>
</div>
</Form>
),
},
];
return (
<div style={{ maxWidth: 720 }}>
<div style={{ maxWidth: 1080 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
@@ -630,6 +714,41 @@ const AdminSettings: React.FC = () => {
</Button>
</div>
{/* 银行账户新增/编辑弹窗 */}
<Modal
title={accountEditing ? '编辑银行账户' : '新增银行账户'}
open={accountModal}
onOk={handleAccountSave}
onCancel={() => setAccountModal(false)}
confirmLoading={accountSaving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="account_name" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]}>
<Input placeholder="例如:民众普康科技有限公司" />
</Form.Item>
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]}>
<Input placeholder="例如:中国工商银行北京分行" />
</Form.Item>
<Form.Item name="account_no" label="银行账号" rules={[{ required: true, message: '请输入银行账号' }]}>
<Input placeholder="银行账号" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={2} placeholder="可选备注信息" />
</Form.Item>
<div style={{ display: 'flex', gap: 24 }}>
<Form.Item name="is_active" label="启用" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
<Form.Item name="is_default" label="设为默认" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
+66 -365
View File
@@ -1,378 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
import {
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
} from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const editStatusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
function statusLabel(status?: string): string {
if (status === 'active') return '启用';
if (status === 'disabled') return '禁用';
return status || '-';
}
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
const AdminTeams: React.FC = () => {
const [items, setItems] = useState<AdminTeam[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
useEffect(()=>{void load();},[load]);
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
keyword: keyword || undefined,
status: status || undefined,
}), [page, pageSize, keyword, status]);
const load = async () => {
setLoading(true);
try {
const res = await getAdminTeams(query);
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载团队列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
setModal({ open: true, item: null });
};
const openEdit = (item: AdminTeam) => {
form.setFieldsValue({
name: item.name,
code: item.code || '',
description: item.description || '',
status: item.status || 'active',
sortOrder: item.sortOrder || 0,
});
setModal({ open: true, item });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await saveAdminTeam({
id: modal.item?.id,
name: values.name,
code: values.code || null,
description: values.description || null,
status: values.status || 'active',
sort_order: values.sortOrder || 0,
});
message.success(modal.item ? '团队已更新' : '团队已创建');
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: AdminTeam) => {
try {
await deleteAdminTeam(item.id);
message.success('团队已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
dataIndex: 'name',
width: 220,
render: (v: string, r: AdminTeam) => (
<Space>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
<TeamOutlined />
</div>
<div>
<Typography.Text strong>{v}</Typography.Text>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
</div>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
},
{
title: '成员数',
dataIndex: 'memberCount',
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
onConfirm={() => handleDelete(r)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
const columns=[
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
{title:'排序',dataIndex:'sortOrder',width:80},
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}></Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button size="small" danger icon={<DeleteOutlined/>}></Button></Popconfirm></Space>},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Input
placeholder="搜索团队名称/编码/备注"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
onPressEnter={load}
style={{ width: 260 }}
allowClear
/>
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
const subColumns=[
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
];
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple"></Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}></Button></Popconfirm>}];
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 个团队`,
}}
scroll={{ x: 1100 }}
/>
</Card>
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}></Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}></Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
<Modal
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
open={modal.open}
confirmLoading={saving}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存"
cancelText="取消"
width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="团队名称" rules={[{ required: true, message: '请输入团队名称' }]}>
<Input maxLength={128} placeholder="请输入团队名称" size="large" />
</Form.Item>
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select size="large" options={editStatusOptions} />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div>
);
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
<Tabs items={[
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
{key:'usage',label:'成员月净消耗',children:<Table
loading={detailLoading}
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
dataSource={usage}
pagination={false}
scroll={{x:1400}}
columns={[
{title:'成员',dataIndex:'username',width:120},
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`${r.tierCode}`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`${r.periodSequence}个月`:'历史周期')},
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`},
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
]}
/>},
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green"></Tag>}]}/>},
]}/>
</Modal>
</div>;
};
export default AdminTeams;
+364 -46
View File
@@ -1,17 +1,22 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Radio, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, SafetyOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -20,12 +25,13 @@ import {
saveUserResourceCapacity,
toggleUserStatus,
updateFrontendUserKind,
updateSingleDeviceLoginOverride,
updateUserTeam,
updateSystemConfig,
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -66,6 +72,15 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -75,6 +90,10 @@ const AdminUsers: React.FC = () => {
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
const [singleDeviceModal, setSingleDeviceModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [singleDeviceValue, setSingleDeviceValue] = useState<boolean | null>(null);
const [singleDeviceSaving, setSingleDeviceSaving] = useState(false);
const [globalSingleDeviceEnabled, setGlobalSingleDeviceEnabled] = useState(false);
const [capacityLoading, setCapacityLoading] = useState(false);
const [capacitySaving, setCapacitySaving] = useState(false);
const [teamSaving, setTeamSaving] = useState(false);
@@ -86,6 +105,9 @@ const AdminUsers: React.FC = () => {
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -127,6 +149,9 @@ const AdminUsers: React.FC = () => {
const formValues: Record<string, string> = {};
credit.forEach(c => { formValues[c.key] = c.value; });
configForm.setFieldsValue(formValues);
// 获取全局单设备登录开关状态
const singleDeviceConfig = configs.find(c => c.key === 'single_device_login_enabled');
setGlobalSingleDeviceEnabled(singleDeviceConfig?.value === 'true');
} catch { /* auth error handled by client */ }
};
loadCreditConfigs();
@@ -164,12 +189,94 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.description);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch { /* validation */ }
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -390,6 +497,20 @@ const AdminUsers: React.FC = () => {
}
};
const handleUpdateSingleDeviceOverride = async (user: AdminUser, value: boolean | null) => {
try {
setSingleDeviceSaving(true);
await updateSingleDeviceLoginOverride(user.id, value);
message.success('已更新单设备登录设置');
setSingleDeviceModal({ open: false, user: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setSingleDeviceSaving(false);
}
};
const isAdminTab = activeTab === 'admin';
const columns = [
@@ -417,11 +538,15 @@ const AdminUsers: React.FC = () => {
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
{Number(r.personalCredits || 0).toLocaleString()} / {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}> {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
@@ -488,15 +613,30 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
title: '操作', key: 'action', width: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -540,6 +680,15 @@ const AdminUsers: React.FC = () => {
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
</Button>
{!isAdminTab && (
<Button type="link" size="small" icon={<SafetyOutlined />}
onClick={() => {
setSingleDeviceValue(r.singleDeviceLoginOverride ?? null);
setSingleDeviceModal({ open: true, user: r });
}}>
</Button>
)}
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
@@ -685,50 +834,174 @@ const AdminUsers: React.FC = () => {
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
<Form form={form} layout="vertical" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="description" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 使 0
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong></Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</Modal>
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
@@ -893,7 +1166,7 @@ const AdminUsers: React.FC = () => {
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="credits" label="初始积分" initialValue={0}>
<Form.Item name="credits" label="初始积分(一个自然月有效)" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
@@ -980,6 +1253,51 @@ const AdminUsers: React.FC = () => {
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><SafetyOutlined /> - {singleDeviceModal.user?.username}</Space>}
open={singleDeviceModal.open}
onOk={() => {
if (singleDeviceModal.user) {
handleUpdateSingleDeviceOverride(singleDeviceModal.user, singleDeviceValue);
}
}}
onCancel={() => setSingleDeviceModal({ open: false, user: null })}
okText="保存" cancelText="取消" width={420}
confirmLoading={singleDeviceSaving}
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<Tag color={globalSingleDeviceEnabled ? 'green' : 'default'}>{globalSingleDeviceEnabled ? '已开启' : '已关闭'}</Tag>
</Typography.Text>
</div>
<Radio.Group
value={singleDeviceValue}
onChange={e => setSingleDeviceValue(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value={null}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{globalSingleDeviceEnabled ? '当前受单设备登录限制' : '当前不受限制'}
</Typography.Text>
</Radio>
<Radio value={true}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
<Radio value={false}>
<Typography.Text></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
</Typography.Text>
</Radio>
</Space>
</Radio.Group>
</Modal>
</div>
);
};
+256 -27
View File
@@ -143,12 +143,16 @@ export interface AdminTeam {
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface AdminTeamOption {
@@ -186,6 +190,9 @@ export interface AdminUser {
email: string;
phone?: string;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
@@ -197,6 +204,7 @@ export interface AdminUser {
allowedMenus?: string[] | null;
resourceCapacity?: ResourceCapacityUsage | null;
privatePortraitAssetLimit: number;
singleDeviceLoginOverride?: boolean | null;
}
export interface DailyCredit {
@@ -248,26 +256,21 @@ export interface AdminStats {
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { label?: string; count: number; amount: number }>;
bySource: Record<string, { label?: string; count: number; amount: number }>;
totalIncome: { label?: string; count: number; amount: number };
today: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
month: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
recent?: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
paymentMethod: string;
status: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
}
export interface ModelConfig {
@@ -868,17 +871,17 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
/** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */
totalConsume: number;
/** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */
/** 总退款:仅真实退款,不含历史 hold_release */
totalRefund: number;
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
/** 兼容字段:与 totalConsume 同真实消费口径 */
totalCharge: number;
/** 独立统计列:预扣占用 hold */
/** 历史兼容字段:hold 占用,不计入 totalConsume */
totalHold: number;
/** 独立统计列:真实退款 refund(含历史 NULL) */
/** 兼容字段:与 totalRefund 同真实退款口径 */
totalRefundReal: number;
/** 独立统计列:预扣释放 hold_releasetype=refund, action=hold_release */
/** 历史兼容字段:hold_release,不计入 totalRefund */
totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number;
@@ -887,19 +890,80 @@ export interface AdminCreditRecordSummary {
generationAttemptCount: number;
imageGenerationCount: number;
videoGenerationCount: number;
/** 子分类消费(图片)仅真实费 charge 口径 */
/** 子分类消费(图片)仅真实费 charge 口径 */
imageConsume: number;
/** 子分类消费(视频)仅真实费 charge 口径 */
/** 子分类消费(视频)仅真实费 charge 口径 */
videoConsume: number;
/** 子分类消费(提词)仅真实费 charge 口径 */
/** 子分类消费(提词)仅真实费 charge 口径 */
textConsume: number;
/** 子分类消费(分析)仅真实费 charge 口径 */
/** 子分类消费(分析)仅真实费 charge 口径 */
analysisConsume: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
}
export interface AdminCreditRecordAllocation {
id: string;
creditBalanceId: string;
sourceAllocationId?: string | null;
allocationAction: string;
allocationActionLabel?: string;
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
creditScope?: string;
creditScopeLabel?: string;
teamId?: string | null;
teamManagerId?: string | null;
subscriptionId?: string | null;
subscriptionNo?: string | null;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
seatId?: string | null;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
validFrom?: string;
expiresAt?: string;
unspentBefore?: number;
unspentAfter?: number;
consumedBefore?: number;
consumedAfter?: number;
}
export interface AdminCreditRecordSubscriptionUsage {
creditScope?: string | null;
creditScopeLabel?: string | null;
subscriptionId?: string | null;
subscriptionNo: string;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
amount: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -917,6 +981,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
balanceDelta?: number;
expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -936,6 +1002,17 @@ export interface AdminCreditRecord {
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sceneNameSnapshot?: string;
requestTime?: string;
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
fundingScope?: string;
fundingScopeLabel?: string;
teamAllocationAmount?: number;
personalAllocationAmount?: number;
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -968,6 +1045,7 @@ export interface AdminCreditRecordQueryParams {
userType?: string;
frontendUserKind?: string;
teamId?: string;
subscriptionNo?: string;
recordType?: string;
type?: string;
creditSubject?: string;
@@ -1442,3 +1520,154 @@ export interface VideoUpscaleConfigSavePayload {
}>;
};
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'team_subscription' | 'credit_addon';
export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
export interface CreditProduct {
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
}
export interface LlmBillingPolicy {
id: string;
sceneCode: string;
sceneName: string;
chargeCredits: number;
isActive: boolean;
version: number;
createdBy?: string | null;
updatedBy?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface LlmCallAttempt {
id: string;
callSequence: number;
retrySequence: number;
modelConfigId?: string | null;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
providerRequestId?: string | null;
requestStartedAt: string;
responseReceivedAt?: string | null;
durationMs?: number | null;
status: string;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
httpStatus?: number | null;
errorMessage?: string | null;
tokenUnavailableReason?: string | null;
postprocessStatus?: string | null;
postprocessError?: string | null;
}
export interface LlmBillingExecution {
id: string;
userId: string;
sceneCode: string;
sceneNameSnapshot: string;
ownerType: string;
ownerId: string;
businessAttemptNo: number;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
requestTime: string;
chargeCredits: number;
status: string;
totalCallCount: number;
successfulCallCount: number;
failedCallCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
refundAvailableCredits: number;
refundExpiredCredits: number;
finalErrorMessage?: string | null;
calls: LlmCallAttempt[];
}
// ── Invoice Types ───────────────────────────────────────
export interface InvoiceItem {
id: string;
invoiceNo: string;
userId: string;
username: string;
phone: string;
headerType: string;
headerName: string;
email: string;
totalAmount: number;
totalCredits: number;
orderCount: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
}
export interface InvoiceOrder {
id: string;
orderNo: string;
amount: number;
credits: number;
}
export interface InvoiceDetail {
id: string;
invoiceNo: string;
userId: string;
headerType: string;
headerName: string;
headerTaxNo: string | null;
headerRegisterAddress: string | null;
headerRegisterPhone: string | null;
headerBankName: string | null;
headerBankAccount: string | null;
email: string;
totalAmount: number;
totalCredits: number;
status: string;
failureReason: string | null;
issuedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
orders: InvoiceOrder[];
}
// ── Bank Account ───────────────────────────────────────
export interface BankAccount {
id: string;
accountName: string;
bankName: string;
accountNo: string;
isActive: boolean;
isDefault: boolean;
description?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
// ── Scheduled Task ─────────────────────────────────────
export interface ScheduledTask {
id: string;
name: string;
taskType: 'external_api' | 'internal_method';
schedule: string;
config?: string | null;
isActive: boolean;
lastRunAt?: string | null;
lastStatus?: string | null;
lastError?: string | null;
createdBy?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
+20
View File
@@ -36,3 +36,23 @@ export function formatDate(iso: string | null | undefined): string {
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function formatDatePrecise(iso: string | null | undefined): string {
if (!iso) return '-';
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
const s = iso.trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
let target = utcMs;
if (tz === 'Z') target += CST_OFFSET * 60000;
else if (tz) {
const sign = tz[0] === '+' ? 1 : -1;
const compact = tz.slice(1).replace(':', '');
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
}
const d = new Date(target);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminbanktransactions.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminscheduledtasks.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: 1a4d1f095fa1
Revises: 20260811_20260811, 8a6d2f4c9b10
Create Date: 2026-08-11 10:30:10.431672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a4d1f095fa1'
down_revision: Union[str, None] = ('20260811_20260811', '8a6d2f4c9b10')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,109 @@
"""发票管理表迁移
创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。
Revision ID: 20260810_20260810
Revises: 2026080601
Create Date: 2026-08-10 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260810_20260810'
down_revision: Union[str, None] = '2026080601'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
# ============================================================
# 1. 创建 invoices 表
# ============================================================
op.create_table(
'invoices',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
sa.Column('header_type', sa.String(16), nullable=False),
sa.Column('header_name', sa.String(128), nullable=False),
sa.Column('header_tax_no', sa.String(32), nullable=True),
sa.Column('header_register_address', sa.String(256), nullable=True),
sa.Column('header_register_phone', sa.String(32), nullable=True),
sa.Column('header_bank_name', sa.String(128), nullable=True),
sa.Column('header_bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=False),
sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
sa.Column('failure_reason', sa.Text, nullable=True),
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
# ============================================================
# 2. 创建 invoice_orders 表
# ============================================================
op.create_table(
'invoice_orders',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
sa.Column('order_no', sa.String(64), nullable=False),
sa.Column('amount', sa.Float, nullable=False, server_default='0'),
sa.Column('credits', sa.Float, nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
# ============================================================
# 3. 表注释和字段注释
# ============================================================
_comment_table('invoices', '发票主表')
_comment_column('invoices', 'id', '主键')
_comment_column('invoices', 'user_id', '申请用户ID')
_comment_column('invoices', 'invoice_no', '发票编号')
_comment_column('invoices', 'header_type', '抬头类型: personal/company')
_comment_column('invoices', 'header_name', '抬头名称')
_comment_column('invoices', 'header_tax_no', '税号')
_comment_column('invoices', 'header_register_address', '注册地址')
_comment_column('invoices', 'header_register_phone', '注册电话')
_comment_column('invoices', 'header_bank_name', '开户行')
_comment_column('invoices', 'header_bank_account', '银行账号')
_comment_column('invoices', 'email', '电子邮箱(必填)')
_comment_column('invoices', 'total_amount', '开票总金额')
_comment_column('invoices', 'total_credits', '总积分')
_comment_column('invoices', 'status', '状态: processing/success/failed')
_comment_column('invoices', 'failure_reason', '失败原因')
_comment_column('invoices', 'issued_at', '开票成功时间')
_comment_table('invoice_orders', '发票-订单关联表')
_comment_column('invoice_orders', 'id', '主键')
_comment_column('invoice_orders', 'invoice_id', '发票ID')
_comment_column('invoice_orders', 'order_id', '订单ID')
_comment_column('invoice_orders', 'order_no', '订单号(冗余)')
_comment_column('invoice_orders', 'amount', '订单金额(冗余)')
_comment_column('invoice_orders', 'credits', '订单积分(冗余)')
def downgrade() -> None:
op.drop_table('invoice_orders')
op.drop_table('invoices')
@@ -0,0 +1,65 @@
"""发票抬头表迁移
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
Revision ID: 20260811_20260811
Revises: 20260810_20260810
Create Date: 2026-08-11 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '20260811_20260811'
down_revision: Union[str, None] = '20260810_20260810'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _comment_table(table_name: str, comment: str) -> None:
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
escaped = comment.replace("'", "''")
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
def upgrade() -> None:
op.create_table(
'invoice_headers',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.String(16), nullable=False),
sa.Column('name', sa.String(128), nullable=False),
sa.Column('tax_no', sa.String(32), nullable=True),
sa.Column('register_address', sa.String(256), nullable=True),
sa.Column('register_phone', sa.String(32), nullable=True),
sa.Column('bank_name', sa.String(128), nullable=True),
sa.Column('bank_account', sa.String(64), nullable=True),
sa.Column('email', sa.String(128), nullable=True),
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_invoice_headers_user', 'invoice_headers', ['user_id'])
# 表注释和字段注释
_comment_table('invoice_headers', '发票抬头表')
_comment_column('invoice_headers', 'id', '主键')
_comment_column('invoice_headers', 'user_id', '用户ID')
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
_comment_column('invoice_headers', 'name', '抬头名称')
_comment_column('invoice_headers', 'tax_no', '税号')
_comment_column('invoice_headers', 'register_address', '注册地址')
_comment_column('invoice_headers', 'register_phone', '注册电话')
_comment_column('invoice_headers', 'bank_name', '开户行')
_comment_column('invoice_headers', 'bank_account', '银行账号')
_comment_column('invoice_headers', 'email', '接收邮箱')
_comment_column('invoice_headers', 'is_default', '是否默认')
def downgrade() -> None:
op.drop_table('invoice_headers')
@@ -0,0 +1,30 @@
"""单设备登录拆分设备类型 + 用户级覆盖
Revision ID: 20260813_split_device_type
Revises: 20260813_token_version
Create Date: 2026-08-13 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_split_device_type'
down_revision = '20260813_token_version'
branch_labels = None
depends_on = None
def upgrade():
# 拆分为按设备类型管理
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'token_version')
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
def downgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
op.drop_column('users', 'single_device_login_override')
op.drop_column('users', 'mobile_token_version')
op.drop_column('users', 'pc_token_version')
@@ -0,0 +1,22 @@
"""增加用户token_version字段 — 单设备登录
Revision ID: 20260813_token_version
Revises:b7e2c4d91a63
Create Date: 2026-08-13 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_token_version'
down_revision = 'b7e2c4d91a63' # 设置为上一个迁移的 revision
branch_labels = None
depends_on = None
def upgrade():
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
def downgrade():
op.drop_column('users', 'token_version')
@@ -0,0 +1,53 @@
"""银行账户与定时任务
Revision ID: 20260813_bank_scheduled
Revises: 20260813_split_device_type
Create Date: 2026-08-13 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260813_bank_scheduled'
down_revision = '20260813_split_device_type'
branch_labels = None
depends_on = None
def upgrade():
# 创建银行账户表
op.create_table(
'bank_accounts',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_name', sa.String(128), nullable=False, comment='账户名称'),
sa.Column('bank_name', sa.String(128), nullable=False, comment='开户银行'),
sa.Column('account_no', sa.String(64), unique=True, nullable=False, comment='银行账号'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('is_default', sa.Boolean, default=False, comment='是否默认账户'),
sa.Column('description', sa.String(256), nullable=True, comment='备注'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
# 创建定时任务表
op.create_table(
'scheduled_tasks',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('name', sa.String(128), nullable=False, comment='任务名称'),
sa.Column('task_type', sa.String(32), nullable=False, comment='类型: external_api / internal_method'),
sa.Column('schedule', sa.String(128), nullable=False, comment='Cron 表达式或间隔秒数'),
sa.Column('config', sa.Text, nullable=True, comment='任务配置 JSON'),
sa.Column('is_active', sa.Boolean, default=True, comment='是否启用'),
sa.Column('last_run_at', sa.String(64), nullable=True, comment='最后执行时间 ISO'),
sa.Column('last_status', sa.String(16), nullable=True, comment='最后执行状态'),
sa.Column('last_error', sa.Text, nullable=True, comment='最后执行错误信息'),
sa.Column('created_by', sa.String(32), nullable=True, comment='创建者管理员 ID'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), comment='创建时间'),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), comment='更新时间'),
)
def downgrade():
# 删除新表
op.drop_table('scheduled_tasks')
op.drop_table('bank_accounts')
@@ -0,0 +1,51 @@
"""银行交易流水表
Revision ID: 20260814_bank_tx
Revises: 20260813_bank_scheduled
Create Date: 2026-08-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20260814_bank_tx'
down_revision = '20260813_bank_scheduled'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'bank_transactions',
sa.Column('id', sa.String(32), primary_key=True),
sa.Column('account_id', sa.String(32), nullable=False, comment='银行账户ID'),
sa.Column('account_no', sa.String(64), nullable=False, comment='银行账号'),
sa.Column('transaction_no', sa.String(128), unique=True, nullable=True, comment='交易流水号(唯一)'),
sa.Column('transaction_time', sa.DateTime(timezone=True), nullable=True, comment='交易时间'),
sa.Column('transaction_amount', sa.String(32), nullable=False, comment='交易金额'),
sa.Column('balance_direction', sa.String(8), nullable=True, comment='借贷方向: DR/CR'),
sa.Column('balance_after', sa.String(32), nullable=True, comment='交易后余额'),
sa.Column('counterparty_name', sa.String(128), nullable=True, comment='对方户名'),
sa.Column('counterparty_account', sa.String(64), nullable=True, comment='对方账号'),
sa.Column('counterparty_bank', sa.String(128), nullable=True, comment='对方开户行'),
sa.Column('remark', sa.Text, nullable=True, comment='摘要/备注'),
sa.Column('digest_code', sa.String(64), nullable=True, comment='摘要码'),
sa.Column('purpose', sa.String(256), nullable=True, comment='用途'),
sa.Column('raw_data', sa.Text, nullable=True, comment='接口返回原始JSON'),
sa.Column('sync_batch', sa.String(32), nullable=True, comment='同步批次号'),
sa.Column('is_synced', sa.Boolean, default=True, comment='是否同步成功'),
)
op.create_index('ix_bank_transactions_account_id', 'bank_transactions', ['account_id'])
op.create_index('ix_bank_transactions_account_no', 'bank_transactions', ['account_no'])
op.create_index('ix_bank_transactions_transaction_time', 'bank_transactions', ['transaction_time'])
op.create_index('ix_bank_transactions_sync_batch', 'bank_transactions', ['sync_batch'])
op.create_index('ix_bank_transactions_account_time', 'bank_transactions', ['account_no', 'transaction_time'])
def downgrade():
op.drop_index('ix_bank_transactions_account_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_sync_batch', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_transaction_time', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_no', table_name='bank_transactions')
op.drop_index('ix_bank_transactions_account_id', table_name='bank_transactions')
op.drop_table('bank_transactions')
@@ -0,0 +1,27 @@
"""银行流水表加 created_at 字段
Revision ID: 20260814_bank_tx_ca
Revises: 20260814_bank_tx
Create Date: 2026-08-14 16:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, use by Alembic.
revision = '20260814_bank_tx_ca'
down_revision = 'e7f2527691bb'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'bank_transactions',
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('ix_bank_transactions_created_at', 'bank_transactions', ['created_at'])
def downgrade():
op.drop_index('ix_bank_transactions_created_at', table_name='bank_transactions')
op.drop_column('bank_transactions', 'created_at')
@@ -0,0 +1,444 @@
"""个人/团队订阅多实例、团队席位与统一线下订单
Revision ID: 20260814_multi_sub_team
Revises: 20260813_split_device_type
Create Date: 2026-08-14 11:31:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "20260814_multi_sub_team"
down_revision = "20260813_bank_scheduled"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def _constraints(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
names = {item.get("name") for item in inspector.get_check_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_unique_constraints(table_name)}
names |= {item.get("name") for item in inspector.get_foreign_keys(table_name)}
return {name for name in names if name}
def _add_column(table: str, column: sa.Column) -> None:
if column.name not in _columns(table):
op.add_column(table, column)
def _drop_column(table: str, name: str) -> None:
if name in _columns(table):
op.drop_column(table, name)
def _drop_index(table: str, name: str) -> None:
if name in _indexes(table):
op.drop_index(name, table_name=table)
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
# Product:上下架与软删除分离;保留续费开关;product_code 保持原全局唯一索引。
_add_column("credit_products", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
if "uq_credit_products_code" not in _indexes("credit_products"):
op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
if "ix_credit_products_deleted_at" not in _indexes("credit_products"):
op.create_index("ix_credit_products_deleted_at", "credit_products", ["deleted_at"], unique=False)
# 旧版本同名索引不含 deleted_at,必须重建,不能只按名称判断存在。
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "deleted_at", "is_active", "sort_order"])
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type IN ('subscription', 'team_subscription') "
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
# Subscription:个人/团队共表,多实例独立,不再保留升级链。
_add_column("user_credit_subscriptions", sa.Column("team_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_type_snapshot", sa.String(24), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("product_name_snapshot", sa.String(96), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("monthly_total_credits_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("quantity_snapshot", sa.Integer(), nullable=True, server_default="1"))
_add_column("user_credit_subscriptions", sa.Column("first_purchase_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("regular_price_snapshot", sa.Numeric(20, 2), nullable=True, server_default="0"))
_add_column("user_credit_subscriptions", sa.Column("activity_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True, server_default="0"))
if "fk_user_credit_subscriptions_team" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_team", "user_credit_subscriptions", "teams", ["team_id"], ["id"], ondelete="RESTRICT"
)
op.execute(
"UPDATE user_credit_subscriptions s SET "
"product_type_snapshot = COALESCE(product_type_snapshot, 'subscription'), "
"product_name_snapshot = COALESCE(product_name_snapshot, NULLIF(s.product_snapshot_json->>'name',''), '历史订阅套餐'), "
"monthly_total_credits_snapshot = COALESCE(monthly_total_credits_snapshot, monthly_grant_credits_snapshot), "
"quantity_snapshot = COALESCE(quantity_snapshot, 1), "
"first_purchase_price_snapshot = COALESCE(first_purchase_price_snapshot, NULLIF(s.product_snapshot_json->>'first_purchase_price','')::numeric, 0), "
"regular_price_snapshot = COALESCE(regular_price_snapshot, NULLIF(s.product_snapshot_json->>'regular_price','')::numeric, paid_amount_snapshot), "
"activity_price_snapshot = COALESCE(activity_price_snapshot, NULLIF(s.product_snapshot_json->>'activity_price','')::numeric), "
"actual_unit_price_snapshot = COALESCE(actual_unit_price_snapshot, paid_amount_snapshot)"
)
for col in (
"product_type_snapshot", "product_name_snapshot", "monthly_total_credits_snapshot",
"quantity_snapshot", "first_purchase_price_snapshot", "regular_price_snapshot", "actual_unit_price_snapshot",
):
if col in _columns("user_credit_subscriptions"):
op.alter_column("user_credit_subscriptions", col, nullable=False)
_drop_column("user_credit_subscriptions", "source_subscription_id")
_drop_column("user_credit_subscriptions", "upgrade_order_id")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_current")
if "ix_user_credit_subscriptions_user_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_user_active", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ix_user_credit_subscriptions_team_active" not in _indexes("user_credit_subscriptions"):
op.create_index("ix_user_credit_subscriptions_team_active", "user_credit_subscriptions", ["team_id", "status", "expires_at"])
# Period:删除升级预留字段,补充窗口索引。
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_upgrade")
_drop_column("user_credit_subscription_periods", "upgrade_order_id")
_drop_column("user_credit_subscription_periods", "reserved_at")
_drop_column("user_credit_subscription_periods", "revoked_at")
if "ix_user_credit_subscription_periods_window" not in _indexes("user_credit_subscription_periods"):
op.create_index(
"ix_user_credit_subscription_periods_window",
"user_credit_subscription_periods",
["subscription_id", "valid_from", "expires_at"],
)
# Balance:增加个人/团队资金域。
_add_column("user_credit_balances", sa.Column("credit_scope", sa.String(16), nullable=True, server_default="personal"))
_add_column("user_credit_balances", sa.Column("team_id", sa.String(32), nullable=True))
op.execute("UPDATE user_credit_balances SET credit_scope='personal' WHERE credit_scope IS NULL")
op.alter_column("user_credit_balances", "credit_scope", nullable=False, server_default="personal")
if "fk_user_credit_balances_team" not in _constraints("user_credit_balances"):
op.create_foreign_key("fk_user_credit_balances_team", "user_credit_balances", "teams", ["team_id"], ["id"], ondelete="RESTRICT")
if "ck_user_credit_balances_scope_fields" not in _constraints("user_credit_balances"):
op.create_check_constraint(
"ck_user_credit_balances_scope_fields",
"user_credit_balances",
"(credit_scope='personal' AND team_id IS NULL) OR "
"(credit_scope='team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL AND subscription_period_id IS NOT NULL)",
)
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
if "ix_user_credit_balances_team_spendable" not in _indexes("user_credit_balances"):
op.create_index(
"ix_user_credit_balances_team_spendable",
"user_credit_balances",
["team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id"],
postgresql_where=sa.text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
)
# Allocation:资金来源、团队任期、Subscription/Period/Seat 全部冷备。
allocation_columns = [
sa.Column("credit_scope_snapshot", sa.String(16), nullable=True, server_default="personal"),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("team_manager_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_id_snapshot", sa.String(32), nullable=True),
sa.Column("subscription_period_id_snapshot", sa.String(32), nullable=True),
sa.Column("seat_id_snapshot", sa.String(32), nullable=True),
]
for column in allocation_columns:
_add_column("credit_record_allocations", column)
op.execute("UPDATE credit_record_allocations SET credit_scope_snapshot='personal' WHERE credit_scope_snapshot IS NULL")
op.alter_column("credit_record_allocations", "credit_scope_snapshot", nullable=False, server_default="personal")
for name, cols in (
("ix_credit_record_allocations_team_time", ["team_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_manager_time", ["team_id_snapshot", "team_manager_id_snapshot", "created_at", "id"]),
("ix_credit_record_allocations_team_period_user", ["subscription_period_id_snapshot", "user_id", "allocation_action"]),
):
if name not in _indexes("credit_record_allocations"):
op.create_index(name, "credit_record_allocations", cols)
# PaymentOrder:线上/线下统一主表,删除升级价格字段。
payment_columns = [
sa.Column("order_source", sa.String(24), nullable=True, server_default="online_payment"),
sa.Column("quantity", sa.Integer(), nullable=True, server_default="1"),
sa.Column("quoted_unit_price_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("quoted_amount_snapshot", sa.Numeric(20, 2), nullable=True),
sa.Column("actual_unit_price_snapshot", sa.Numeric(20, 6), nullable=True),
sa.Column("team_id_snapshot", sa.String(32), nullable=True),
sa.Column("operator_admin_id", sa.String(32), nullable=True),
sa.Column("offline_trade_no", sa.String(128), nullable=True),
sa.Column("offline_payment_detail", sa.String(128), nullable=True),
sa.Column("remark", sa.String(512), nullable=True),
sa.Column("refund_entitlement_status", sa.String(32), nullable=True),
]
for column in payment_columns:
_add_column("payment_orders", column)
op.execute(
"UPDATE payment_orders SET order_source=COALESCE(order_source,'online_payment'), quantity=COALESCE(quantity,1), "
"quoted_amount_snapshot=COALESCE(quoted_amount_snapshot, amount), "
"quoted_unit_price_snapshot=COALESCE(quoted_unit_price_snapshot, amount), "
"actual_unit_price_snapshot=COALESCE(actual_unit_price_snapshot, amount)"
)
op.alter_column("payment_orders", "order_source", nullable=False, server_default="online_payment")
op.alter_column("payment_orders", "quantity", nullable=False, server_default="1")
for col in (
"source_subscription_id", "upgrade_period_ids_json", "target_price_snapshot",
"deduction_amount_snapshot", "payable_amount_snapshot",
):
_drop_column("payment_orders", col)
if "fk_payment_orders_team_snapshot" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_team_snapshot", "payment_orders", "teams", ["team_id_snapshot"], ["id"], ondelete="RESTRICT")
if "fk_payment_orders_operator_admin" not in _constraints("payment_orders"):
op.create_foreign_key("fk_payment_orders_operator_admin", "payment_orders", "users", ["operator_admin_id"], ["id"], ondelete="SET NULL")
if "ix_payorder_source_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_source_status_created", "payment_orders", ["order_source", "status", "created_at"])
if "ix_payorder_team_status_created" not in _indexes("payment_orders"):
op.create_index("ix_payorder_team_status_created", "payment_orders", ["team_id_snapshot", "status", "created_at"])
_add_column("teams", sa.Column("first_subscription_paid_at", sa.DateTime(timezone=True), nullable=True))
if "ix_teams_first_subscription_paid_at" not in _indexes("teams"):
op.create_index("ix_teams_first_subscription_paid_at", "teams", ["first_subscription_paid_at"])
if bind.dialect.name == "postgresql":
op.execute("CREATE SEQUENCE IF NOT EXISTS team_auto_name_seq START WITH 1 INCREMENT BY 1")
# 避免历史上已经存在“团队000N”时从1开始产生重名;is_called=false 让下一次 nextval 直接返回 max+1。
op.execute(
"SELECT setval('team_auto_name_seq', "
"GREATEST(COALESCE(MAX((substring(name from '^团队([0-9]+)$'))::bigint), 0) + 1, 1), false) "
"FROM teams WHERE name ~ '^团队[0-9]+$'"
)
if "team_manager_history" not in tables:
op.create_table(
"team_manager_history",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("manager_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_team_manager_history_team_time", "team_manager_history", ["team_id", "started_at", "ended_at"])
op.create_index("ix_team_manager_history_manager_time", "team_manager_history", ["manager_user_id", "started_at", "ended_at"])
op.create_index(
"uq_team_manager_history_current",
"team_manager_history",
["team_id"],
unique=True,
postgresql_where=sa.text("ended_at IS NULL"),
)
op.execute(
"INSERT INTO team_manager_history(id, team_id, manager_user_id, started_at, created_at, updated_at) "
"SELECT md5(random()::text || clock_timestamp()::text), id, manager_id, COALESCE(created_at, now()), now(), now() "
"FROM teams WHERE manager_id IS NOT NULL AND deleted_at IS NULL"
)
if "team_subscription_seats" not in tables:
op.create_table(
"team_subscription_seats",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("team_id", sa.String(32), sa.ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("monthly_allocated_credits", sa.Numeric(20, 2), nullable=False),
sa.Column("created_by_user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
)
op.create_index("ix_team_subscription_seats_subscription", "team_subscription_seats", ["subscription_id", "created_at"])
op.create_index("ix_team_subscription_seats_team_id", "team_subscription_seats", ["team_id"])
op.create_index("ix_team_subscription_seats_deleted_at", "team_subscription_seats", ["deleted_at"])
op.create_index("ix_team_subscription_seats_user", "team_subscription_seats", ["user_id", "subscription_id"])
op.create_index(
"uq_team_subscription_seats_active_user",
"team_subscription_seats",
["subscription_id", "user_id"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL AND cancelled_at IS NULL"),
)
if "team_subscription_seat_usages" not in tables:
op.create_table(
"team_subscription_seat_usages",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("seat_id", sa.String(32), sa.ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_id", sa.String(32), sa.ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False),
sa.Column("subscription_period_id", sa.String(32), sa.ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False),
sa.Column("user_id", sa.String(32), sa.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False),
sa.Column("used_credits", sa.Numeric(20, 2), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
)
op.create_index(
"uq_team_subscription_seat_usage_period",
"team_subscription_seat_usages",
["seat_id", "subscription_period_id"],
unique=True,
)
op.create_index("ix_team_subscription_seat_usage_member", "team_subscription_seat_usages", ["subscription_period_id", "user_id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if "team_subscription_seat_usages" in tables:
op.drop_table("team_subscription_seat_usages")
if "team_subscription_seats" in tables:
op.drop_table("team_subscription_seats")
if "team_manager_history" in tables:
op.drop_table("team_manager_history")
if bind.dialect.name == "postgresql":
op.execute("DROP SEQUENCE IF EXISTS team_auto_name_seq")
_drop_index("teams", "ix_teams_first_subscription_paid_at")
_drop_column("teams", "first_subscription_paid_at")
for name in ("ix_payorder_team_status_created", "ix_payorder_source_status_created"):
_drop_index("payment_orders", name)
for fk in ("fk_payment_orders_operator_admin", "fk_payment_orders_team_snapshot"):
if fk in _constraints("payment_orders"):
op.drop_constraint(fk, "payment_orders", type_="foreignkey")
for col in (
"refund_entitlement_status", "remark", "offline_payment_detail", "offline_trade_no",
"operator_admin_id", "team_id_snapshot", "actual_unit_price_snapshot", "quoted_amount_snapshot",
"quoted_unit_price_snapshot", "quantity", "order_source",
):
_drop_column("payment_orders", col)
_add_column("payment_orders", sa.Column("source_subscription_id", sa.String(32), nullable=True))
if "ix_payment_orders_source_subscription_id" not in _indexes("payment_orders"):
op.create_index("ix_payment_orders_source_subscription_id", "payment_orders", ["source_subscription_id"])
_add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
_add_column("payment_orders", sa.Column("target_price_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("deduction_amount_snapshot", sa.Numeric(20, 2), nullable=True))
_add_column("payment_orders", sa.Column("payable_amount_snapshot", sa.Numeric(20, 2), nullable=True))
for name in (
"ix_credit_record_allocations_team_period_user",
"ix_credit_record_allocations_team_manager_time",
"ix_credit_record_allocations_team_time",
):
_drop_index("credit_record_allocations", name)
for col in (
"seat_id_snapshot", "subscription_period_id_snapshot", "subscription_id_snapshot",
"team_manager_id_snapshot", "team_id_snapshot", "credit_scope_snapshot",
):
_drop_column("credit_record_allocations", col)
_drop_index("user_credit_balances", "ix_user_credit_balances_team_spendable")
_drop_index("user_credit_balances", "ix_user_credit_balances_spendable")
if "ck_user_credit_balances_scope_fields" in _constraints("user_credit_balances"):
op.drop_constraint("ck_user_credit_balances_scope_fields", "user_credit_balances", type_="check")
if "fk_user_credit_balances_team" in _constraints("user_credit_balances"):
op.drop_constraint("fk_user_credit_balances_team", "user_credit_balances", type_="foreignkey")
_drop_column("user_credit_balances", "team_id")
_drop_column("user_credit_balances", "credit_scope")
op.create_index(
"ix_user_credit_balances_spendable",
"user_credit_balances",
["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
)
_drop_index("user_credit_subscription_periods", "ix_user_credit_subscription_periods_window")
_add_column("user_credit_subscription_periods", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscription_periods_upgrade_order" not in _constraints("user_credit_subscription_periods"):
op.create_foreign_key(
"fk_user_credit_subscription_periods_upgrade_order",
"user_credit_subscription_periods", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
_add_column("user_credit_subscription_periods", sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True))
_add_column("user_credit_subscription_periods", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
op.create_index("ix_user_credit_subscription_periods_upgrade", "user_credit_subscription_periods", ["upgrade_order_id", "status"])
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_team_active")
_drop_index("user_credit_subscriptions", "ix_user_credit_subscriptions_user_active")
if "fk_user_credit_subscriptions_team" in _constraints("user_credit_subscriptions"):
op.drop_constraint("fk_user_credit_subscriptions_team", "user_credit_subscriptions", type_="foreignkey")
for col in (
"actual_unit_price_snapshot", "activity_price_snapshot", "regular_price_snapshot",
"first_purchase_price_snapshot", "quantity_snapshot", "monthly_total_credits_snapshot",
"product_name_snapshot", "product_type_snapshot", "team_manager_id_snapshot", "team_id",
):
_drop_column("user_credit_subscriptions", col)
_add_column("user_credit_subscriptions", sa.Column("source_subscription_id", sa.String(32), nullable=True))
_add_column("user_credit_subscriptions", sa.Column("upgrade_order_id", sa.String(32), nullable=True))
if "fk_user_credit_subscriptions_source_subscription" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_source_subscription",
"user_credit_subscriptions", "user_credit_subscriptions", ["source_subscription_id"], ["id"], ondelete="SET NULL"
)
if "fk_user_credit_subscriptions_upgrade_order" not in _constraints("user_credit_subscriptions"):
op.create_foreign_key(
"fk_user_credit_subscriptions_upgrade_order",
"user_credit_subscriptions", "payment_orders", ["upgrade_order_id"], ["id"], ondelete="SET NULL"
)
op.create_index("ix_user_credit_subscriptions_current", "user_credit_subscriptions", ["user_id", "status", "expires_at"])
if "ck_credit_products_type_required_fields" in _constraints("credit_products"):
op.drop_constraint("ck_credit_products_type_required_fields", "credit_products", type_="check")
# 旧版本不认识 team_subscription。降级时保留商品与永久唯一 product_code
# 但将团队套餐转换为旧版可识别的 subscription 并强制下架,避免旧代码误售。
op.execute(
"UPDATE credit_products "
"SET product_type='subscription', is_active=false "
"WHERE product_type='team_subscription'"
)
op.create_check_constraint(
"ck_credit_products_type_required_fields",
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
_add_column("credit_products", sa.Column("renewal_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.execute("UPDATE credit_products SET renewal_enabled=false WHERE product_type='credit_addon'")
_drop_index("credit_products", "ix_credit_products_public")
op.create_index("ix_credit_products_public", "credit_products", ["product_type", "is_active", "sort_order"])
_drop_index("credit_products", "ix_credit_products_deleted_at")
_drop_column("credit_products", "deleted_at")
@@ -0,0 +1,50 @@
"""兼容恢复积分套餐续费开关
Revision ID: 20260814_restore_renewal
Revises: 20260814_multi_sub_team
Create Date: 2026-08-14 13:25:00
说明:
- 修正版 20260814_multi_sub_team 已不再删除 renewal_enabled;新环境执行到本迁移时为 no-op。
- 如果数据库已经执行过上一版会删除 renewal_enabled 的同 revision 迁移,本迁移负责安全补回字段。
- 已经被旧迁移删除的历史 false 值无法从数据库自身恢复,补回时订阅套餐默认 true,积分增值包统一 false。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_restore_renewal"
down_revision = "20260814_multi_sub_team"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def upgrade() -> None:
if "renewal_enabled" not in _columns("credit_products"):
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
op.execute(
"UPDATE credit_products "
"SET renewal_enabled=false "
"WHERE product_type='credit_addon'"
)
def downgrade() -> None:
# 当前修正版上一个 revision 本身就保留 renewal_enabled,降级到它时字段也应继续存在。
pass
@@ -0,0 +1,92 @@
"""订阅实例业务流水号
Revision ID: 20260814_subscription_no
Revises: 20260814_restore_renewal
Create Date: 2026-08-14 14:23:00
说明:
- 每张 user_credit_subscriptions 增加永久唯一 subscription_no。
- 个人订阅前缀 PS,团队订阅前缀 TS。
- 全局共用 PostgreSQL Sequence,避免 COUNT/MAX 并发冲突。
- 历史记录按原 created_at 日期生成业务编号;编号只用于展示/客服定位,不参与业务排序和结算。
"""
from alembic import op
import sqlalchemy as sa
revision = "20260814_subscription_no"
down_revision = "20260814_restore_renewal"
branch_labels = None
depends_on = None
def _columns(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_columns(table_name)}
def _indexes(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return set()
return {item["name"] for item in inspector.get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name != "postgresql":
raise RuntimeError("订阅实例流水号迁移当前仅支持 PostgreSQL")
op.execute("CREATE SEQUENCE IF NOT EXISTS credit_subscription_no_seq START WITH 1 INCREMENT BY 1")
if "subscription_no" not in _columns("user_credit_subscriptions"):
op.add_column(
"user_credit_subscriptions",
sa.Column(
"subscription_no",
sa.String(length=32),
nullable=True,
comment="订阅业务实例编号,供用户/客服/开发定位",
),
)
# 历史数据一次性补号。日期使用东八区业务日期;Sequence 只保证唯一,不要求无跳号。
op.execute(
"""
UPDATE user_credit_subscriptions
SET subscription_no =
CASE
WHEN product_type_snapshot = 'team_subscription' THEN 'TS'
ELSE 'PS'
END
|| to_char(COALESCE(created_at, start_at, now()) AT TIME ZONE 'Asia/Shanghai', 'YYYYMMDD')
|| lpad(nextval('credit_subscription_no_seq')::text, 6, '0')
WHERE subscription_no IS NULL OR btrim(subscription_no) = ''
"""
)
op.alter_column(
"user_credit_subscriptions",
"subscription_no",
existing_type=sa.String(length=32),
nullable=False,
)
if "uq_user_credit_subscriptions_no" not in _indexes("user_credit_subscriptions"):
op.create_index(
"uq_user_credit_subscriptions_no",
"user_credit_subscriptions",
["subscription_no"],
unique=True,
)
def downgrade() -> None:
if "uq_user_credit_subscriptions_no" in _indexes("user_credit_subscriptions"):
op.drop_index("uq_user_credit_subscriptions_no", table_name="user_credit_subscriptions")
if "subscription_no" in _columns("user_credit_subscriptions"):
op.drop_column("user_credit_subscriptions", "subscription_no")
op.execute("DROP SEQUENCE IF EXISTS credit_subscription_no_seq")
@@ -0,0 +1,650 @@
"""add credit product renewal enabled
Revision ID: 8a6d2f4c9b10
Revises: 7f3c8a2d9e41
Create Date: 2026-08-06 17:20:00
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from alembic import op
import sqlalchemy as sa
revision = "8a6d2f4c9b10"
down_revision = "7f3c8a2d9e41"
branch_labels = None
depends_on = None
_PRODUCT_UPSERT_SQL = sa.text(
"""
INSERT INTO credit_products (
id,
product_code,
product_type,
name,
description,
features_json,
tier_code,
tier_rank,
billing_cycle,
monthly_grant_credits,
first_purchase_price,
regular_price,
activity_price,
activity_start_at,
activity_end_at,
grant_credits,
validity_months,
price,
credit_level,
currency,
is_active,
sort_order,
created_at,
updated_at,
renewal_enabled
) VALUES (
:id,
:product_code,
:product_type,
:name,
:description,
CAST(:features_json AS JSON),
:tier_code,
:tier_rank,
:billing_cycle,
:monthly_grant_credits,
:first_purchase_price,
:regular_price,
:activity_price,
:activity_start_at,
:activity_end_at,
:grant_credits,
:validity_months,
:price,
:credit_level,
:currency,
:is_active,
:sort_order,
:created_at,
:updated_at,
:renewal_enabled
)
ON CONFLICT (product_code) DO UPDATE SET
product_type = EXCLUDED.product_type,
name = EXCLUDED.name,
description = EXCLUDED.description,
features_json = EXCLUDED.features_json,
tier_code = EXCLUDED.tier_code,
tier_rank = EXCLUDED.tier_rank,
billing_cycle = EXCLUDED.billing_cycle,
monthly_grant_credits = EXCLUDED.monthly_grant_credits,
first_purchase_price = EXCLUDED.first_purchase_price,
regular_price = EXCLUDED.regular_price,
activity_price = EXCLUDED.activity_price,
activity_start_at = EXCLUDED.activity_start_at,
activity_end_at = EXCLUDED.activity_end_at,
grant_credits = EXCLUDED.grant_credits,
validity_months = EXCLUDED.validity_months,
price = EXCLUDED.price,
credit_level = EXCLUDED.credit_level,
currency = EXCLUDED.currency,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at,
renewal_enabled = EXCLUDED.renewal_enabled
"""
)
_POLICY_UPSERT_SQL = sa.text(
"""
INSERT INTO llm_billing_policies (
id,
scene_code,
scene_name,
pre_deduct_credits,
is_active,
version,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:id,
:scene_code,
:scene_name,
:pre_deduct_credits,
:is_active,
:version,
:created_by,
:updated_by,
:created_at,
:updated_at
)
ON CONFLICT (scene_code) DO UPDATE SET
scene_name = EXCLUDED.scene_name,
pre_deduct_credits = EXCLUDED.pre_deduct_credits,
is_active = EXCLUDED.is_active,
version = EXCLUDED.version,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at
"""
)
def _dt(value: str) -> datetime:
return datetime.fromisoformat(value)
def _seed_credit_products() -> None:
rows = [
{
"id": "0019fd653e4680767ab",
"product_code": "1",
"product_type": "subscription",
"name": "入门",
"description": None,
"features_json": "[]",
"tier_code": "入门",
"tier_rank": 1,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("1000.00"),
"first_purchase_price": Decimal("79.00"),
"regular_price": Decimal("122.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("122.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T09:07:30.020043+08:00"),
"updated_at": _dt("2026-08-06T09:52:15.579827+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd67e8a786dda0e",
"product_code": "2",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("2000.00"),
"first_purchase_price": Decimal("154.00"),
"regular_price": Decimal("239.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("239.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"updated_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd67f4c8bf75dd1",
"product_code": "3",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("3000.00"),
"first_purchase_price": Decimal("221.00"),
"regular_price": Decimal("353.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("353.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T09:54:54.768868+08:00"),
"updated_at": _dt("2026-08-06T09:55:10.408471+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd680784c16dd15",
"product_code": "4",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("4000.00"),
"first_purchase_price": Decimal("280.00"),
"regular_price": Decimal("476.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("476.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"updated_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68270abc3dcbd",
"product_code": "5",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("5000.00"),
"first_purchase_price": Decimal("1029.00"),
"regular_price": Decimal("1499.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1499.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 5,
"created_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"updated_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68373d24dbab0",
"product_code": "6",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("6000.00"),
"first_purchase_price": Decimal("1197.00"),
"regular_price": Decimal("1791.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1791.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 6,
"created_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"updated_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68469706aa522",
"product_code": "7",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("7000.00"),
"first_purchase_price": Decimal("1367.00"),
"regular_price": Decimal("2079.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("2079.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 7,
"created_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"updated_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd6904f72f59505",
"product_code": "8",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("10000.00"),
"first_purchase_price": Decimal("7308.00"),
"regular_price": Decimal("8399.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("8399.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 8,
"created_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"updated_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd692bc5e42b8be",
"product_code": "9",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("15000.00"),
"first_purchase_price": Decimal("10710.00"),
"regular_price": Decimal("12586.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("12586.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 9,
"created_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"updated_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69417fabda8c6",
"product_code": "10",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("20000.00"),
"first_purchase_price": Decimal("13440.00"),
"regular_price": Decimal("16685.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("16685.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 10,
"created_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"updated_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69a07e363c7b5",
"product_code": "ZLB-A",
"product_type": "credit_addon",
"name": "增量包A",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("500.00"),
"validity_months": 1,
"price": Decimal("0.01"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"updated_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69a640d7ccc92",
"product_code": "ZLB-B",
"product_type": "credit_addon",
"name": "增量包B",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1000.00"),
"validity_months": 1,
"price": Decimal("0.02"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T10:24:30.256294+08:00"),
"updated_at": _dt("2026-08-06T10:24:35.878389+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69ae039d3ba69",
"product_code": "ZLB-C",
"product_type": "credit_addon",
"name": "增量包C",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1500.00"),
"validity_months": 1,
"price": Decimal("0.03"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"updated_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69b3166a6a71c",
"product_code": "ZLB-D",
"product_type": "credit_addon",
"name": "增量包D",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("2000.00"),
"validity_months": 1,
"price": Decimal("0.04"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T10:25:22.811498+08:00"),
"updated_at": _dt("2026-08-06T10:25:29.647103+08:00"),
"renewal_enabled": False,
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_PRODUCT_UPSERT_SQL, row)
def _seed_llm_billing_policies() -> None:
admin_user_id = "0019e0a44895b6d837d"
rows = [
{
"id": "0019fd6aa5084a85a96",
"scene_code": "generation_record_text_prompt_optimize",
"scene_name": "AI创作-提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:41:53.809940+08:00"),
"updated_at": _dt("2026-08-06T10:41:53.809940+08:00"),
},
{
"id": "0019fd6aa834169f61d",
"scene_code": "hot_opening_image_prompt_optimize",
"scene_name": "爆款开头复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:06.818840+08:00"),
"updated_at": _dt("2026-08-06T10:42:06.818840+08:00"),
},
{
"id": "0019fd6aac862f821ca",
"scene_code": "shot_image_prompt_optimize",
"scene_name": "拆镜复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:24.496938+08:00"),
"updated_at": _dt("2026-08-06T10:42:24.496938+08:00"),
},
{
"id": "0019fd6aaf2fcae0765",
"scene_code": "shot_video_prompt_optimize",
"scene_name": "拆镜复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:35.404414+08:00"),
"updated_at": _dt("2026-08-06T10:42:35.404414+08:00"),
},
{
"id": "0019fd6aaaf31d7c7c7",
"scene_code": "hot_opening_video_prompt_optimize",
"scene_name": "爆款开头复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:18.003357+08:00"),
"updated_at": _dt("2026-08-06T10:42:39.440194+08:00"),
},
{
"id": "0019fd6abba149ef7e9",
"scene_code": "shot_original_video_analysis",
"scene_name": "拆镜复刻-原视频AI分析",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:26.390337+08:00"),
"updated_at": _dt("2026-08-06T10:43:26.390337+08:00"),
},
{
"id": "0019fd6abdbc4182c17",
"scene_code": "shot_segment_video_analysis",
"scene_name": "拆镜复刻-片段视频AI分析",
"pre_deduct_credits": Decimal("10.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:35.017213+08:00"),
"updated_at": _dt("2026-08-06T10:43:35.017213+08:00"),
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_POLICY_UPSERT_SQL, row)
def upgrade() -> None:
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
# 积分增值包不参与订阅续费,统一标记为关闭;已有订阅套餐默认保持开启。
op.execute(
"UPDATE credit_products "
"SET renewal_enabled = false "
"WHERE product_type = 'credit_addon'"
)
_seed_credit_products()
_seed_llm_billing_policies()
def downgrade() -> None:
# 产品和计费策略属于运营配置,降级仅撤销本次新增字段,不主动删除业务配置数据。
op.drop_column("credit_products", "renewal_enabled")
@@ -0,0 +1,75 @@
"""expand credit addon validity months to 1-36
Revision ID: b7e2c4d91a63
Revises: 1a4d1f095fa1
Create Date: 2026-08-11 13:15:00
"""
from alembic import op
revision = "b7e2c4d91a63"
down_revision = "1a4d1f095fa1"
branch_labels = None
depends_on = None
CONSTRAINT_NAME = "ck_credit_products_type_required_fields"
def _drop_type_constraint_if_exists() -> None:
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'credit_products'
AND c.conname = '{CONSTRAINT_NAME}'
) THEN
ALTER TABLE credit_products DROP CONSTRAINT {CONSTRAINT_NAME};
END IF;
END
$$;
"""
)
def _create_type_constraint(*, addon_condition: str) -> None:
op.create_check_constraint(
CONSTRAINT_NAME,
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
f"AND {addon_condition} AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
def upgrade() -> None:
# 当前版本中增值包被旧约束固定为 validity_months = 1。
# 先移除旧约束,再开放为1-36个自然月;不修改任何现有商品数据。
_drop_type_constraint_if_exists()
_create_type_constraint(addon_condition="validity_months BETWEEN 1 AND 36")
def downgrade() -> None:
# 回退到旧版本时,旧约束只允许1个月。为保证 downgrade 可执行,
# 将现有增值包有效期恢复为旧版本唯一合法值1个月。
_drop_type_constraint_if_exists()
op.execute(
"UPDATE credit_products "
"SET validity_months = 1 "
"WHERE product_type = 'credit_addon' "
"AND validity_months IS DISTINCT FROM 1"
)
_create_type_constraint(addon_condition="validity_months = 1")
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: e7f2527691bb
Revises: 20260814_bank_tx, 20260814_subscription_no
Create Date: 2026-08-14 15:32:14.351079
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e7f2527691bb'
down_revision: Union[str, None] = ('20260814_bank_tx', '20260814_subscription_no')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,3 @@
from app.admin_api.bank.routes import router
__all__ = ["router"]
+222
View File
@@ -0,0 +1,222 @@
"""银行账户管理与交易查询后台路由。"""
from datetime import datetime, time
from fastapi import APIRouter, Depends, HTTPException, Path, Query
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.bank_account import BankAccount
from app.models.bank_transaction import BankTransaction
from app.models.user import User
from app.services.bank.service import query_transactions_with_log
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/bank", tags=["admin-bank"])
# ============================================================
# 银行账户 CRUD
# ============================================================
@router.get("/accounts")
async def list_accounts(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有银行账户。"""
result = await db.execute(select(BankAccount).order_by(BankAccount.is_default.desc(), BankAccount.created_at.desc()))
accounts = result.scalars().all()
return {"items": [
{
"id": a.id,
"account_name": a.account_name,
"bank_name": a.bank_name,
"account_no": a.account_no,
"is_active": a.is_active,
"is_default": a.is_default,
"description": a.description,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in accounts
]}
@router.post("/accounts")
async def create_account(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""新增银行账户。"""
account_name = (body.get("account_name") or "").strip()
bank_name = (body.get("bank_name") or "").strip()
account_no = (body.get("account_no") or "").strip()
if not account_name or not bank_name or not account_no:
raise HTTPException(status_code=400, detail="账户名称、开户银行、银行账号不能为空")
# 检查账号唯一性
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == account_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
is_default = bool(body.get("is_default", False))
# 如果设为默认,取消其他默认
if is_default:
await db.execute(
BankAccount.__table__.update().where(BankAccount.is_default.is_(True)).values(is_default=False)
)
account = BankAccount(
id=generate_id(),
account_name=account_name,
bank_name=bank_name,
account_no=account_no,
is_active=bool(body.get("is_active", True)),
is_default=is_default,
description=body.get("description"),
)
db.add(account)
await db.commit()
return {"id": account.id, "message": "创建成功"}
@router.put("/accounts/{account_id}")
async def update_account(
account_id: str = Path(..., description="账户 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""编辑银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
if "account_name" in body:
account.account_name = str(body["account_name"]).strip()
if "bank_name" in body:
account.bank_name = str(body["bank_name"]).strip()
if "account_no" in body:
new_no = str(body["account_no"]).strip()
if new_no != account.account_no:
existing = await db.execute(select(BankAccount).where(BankAccount.account_no == new_no))
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该银行账号已存在")
account.account_no = new_no
if "is_active" in body:
account.is_active = bool(body["is_active"])
if "description" in body:
account.description = body.get("description")
if body.get("is_default"):
await db.execute(
BankAccount.__table__.update()
.where(BankAccount.is_default.is_(True))
.where(BankAccount.id != account_id)
.values(is_default=False)
)
account.is_default = True
await db.commit()
return {"message": "更新成功"}
@router.delete("/accounts/{account_id}")
async def delete_account(
account_id: str = Path(..., description="账户 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除银行账户。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="账户不存在")
await db.delete(account)
await db.commit()
return {"message": "删除成功"}
# ============================================================
# 银行交易查询
# ============================================================
@router.get("/transactions")
async def list_transactions(
account_id: str = Query(..., description="银行账户 ID"),
start_date: str = Query(..., description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(..., description="结束日期 (YYYY-MM-DD)"),
dc_flag: int | None = Query(None, description="借贷方向: 0-借/出金, 1-贷/入金, 不传返回全部"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""查询本地已同步的银行交易流水。"""
result = await db.execute(select(BankAccount).where(BankAccount.id == account_id))
account = result.scalar_one_or_none()
if account is None:
raise HTTPException(status_code=404, detail="银行账户不存在")
# 构建查询条件(日期字符串转为 datetime,避免 PostgreSQL 类型错误)
conditions = [BankTransaction.account_id == account_id]
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
conditions.append(BankTransaction.transaction_time >= start_dt)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
)
conditions.append(BankTransaction.transaction_time <= end_dt)
if dc_flag is not None:
direction = "DR" if dc_flag == 0 else "CR"
conditions.append(BankTransaction.balance_direction == direction)
# 查总数
count_result = await db.execute(
select(sa.func.count()).where(*conditions)
)
total = count_result.scalar() or 0
# 查分页数据
offset = (page - 1) * page_size
data_result = await db.execute(
select(BankTransaction)
.where(*conditions)
.order_by(BankTransaction.transaction_time.desc())
.offset(offset)
.limit(page_size)
)
transactions = data_result.scalars().all()
return {
"items": [
{
"id": t.id,
"account_id": t.account_id,
"account_no": t.account_no,
"transaction_no": t.transaction_no,
"transaction_time": t.transaction_time.isoformat() if t.transaction_time else None,
"transaction_amount": t.transaction_amount,
"balance_direction": t.balance_direction,
"balance_after": t.balance_after,
"counterparty_name": t.counterparty_name,
"counterparty_account": t.counterparty_account,
"counterparty_bank": t.counterparty_bank,
"remark": t.remark,
"digest_code": t.digest_code,
"sync_batch": t.sync_batch,
"is_synced": t.is_synced,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in transactions
],
"total": total,
}
@@ -0,0 +1,3 @@
from app.admin_api.scheduled_tasks.routes import router
__all__ = ["router"]
@@ -0,0 +1,171 @@
"""定时任务管理后台路由。"""
import json
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.scheduled_task import ScheduledTask
from app.models.user import User
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/scheduled-tasks", tags=["admin-scheduled-tasks"])
def _task_to_dict(task: ScheduledTask) -> dict:
return {
"id": task.id,
"name": task.name,
"task_type": task.task_type,
"schedule": task.schedule,
"config": task.config,
"is_active": task.is_active,
"last_run_at": task.last_run_at,
"last_status": task.last_status,
"last_error": task.last_error,
"created_by": task.created_by,
"created_at": task.created_at.isoformat() if task.created_at else None,
"updated_at": task.updated_at.isoformat() if task.updated_at else None,
}
@router.get("")
async def list_tasks(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""列出所有定时任务。"""
result = await db.execute(select(ScheduledTask).order_by(ScheduledTask.created_at.desc()))
tasks = result.scalars().all()
return {"items": [_task_to_dict(t) for t in tasks]}
@router.post("")
async def create_task(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""创建定时任务。"""
name = (body.get("name") or "").strip()
task_type = (body.get("task_type") or "internal_method").strip()
schedule = (body.get("schedule") or "").strip()
if not name or not schedule:
raise HTTPException(status_code=400, detail="任务名称、调度表达式不能为空")
if task_type != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
config = body.get("config")
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
# 验证 JSON 合法性
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task = ScheduledTask(
id=generate_id(),
name=name,
task_type=task_type,
schedule=schedule,
config=config,
is_active=bool(body.get("is_active", True)),
created_by=admin.id,
)
db.add(task)
await db.commit()
return {"id": task.id, "message": "创建成功"}
@router.put("/{task_id}")
async def update_task(
task_id: str = Path(..., description="任务 ID"),
body: dict = ...,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if "name" in body:
task.name = str(body["name"]).strip()
if "task_type" in body:
t = body["task_type"]
if t != "internal_method":
raise HTTPException(status_code=400, detail="任务类型仅支持 internal_method")
task.task_type = t
if "schedule" in body:
task.schedule = str(body["schedule"]).strip()
if "is_active" in body:
task.is_active = bool(body["is_active"])
if "config" in body:
config = body["config"]
if isinstance(config, dict):
config = json.dumps(config, ensure_ascii=False)
elif isinstance(config, str):
try:
json.loads(config)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="config 不是合法的 JSON")
task.config = config
await db.commit()
return {"message": "更新成功"}
@router.delete("/{task_id}")
async def delete_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""删除定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
await db.delete(task)
await db.commit()
return {"message": "删除成功"}
@router.post("/{task_id}/run")
async def run_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""手动执行一次定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
from app.tasks.celery_app import RECOVERY_QUEUE
from app.tasks.scheduled_tasks import execute_scheduled_task
execute_scheduled_task.apply_async(args=[task_id], queue=RECOVERY_QUEUE)
return {"message": "任务已提交执行"}
@router.post("/{task_id}/toggle")
async def toggle_task(
task_id: str = Path(..., description="任务 ID"),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""启用/禁用定时任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
task.is_active = not task.is_active
await db.commit()
return {"is_active": task.is_active, "message": "已启用" if task.is_active else "已禁用"}
+8
View File
@@ -7,12 +7,16 @@ from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
from app.api.admin.private_portrait import router as private_portrait_router
from app.api.admin.recharge_package import router as recharge_package_router
from app.api.admin.credit_management import router as credit_management_router
from app.api.admin.llm_billing import router as llm_billing_router
from app.api.admin.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router
from app.api.admin.contact import router as admin_contact_router
from app.admin_api.api_keys import router as api_keys_admin_router
from app.admin_api.api_model_pricings import router as api_model_pricings_admin_router
from app.admin_api.vp_v3_quota import router as vp_v3_quota_admin_router
from app.admin_api.bank import router as bank_admin_router
from app.admin_api.scheduled_tasks import router as scheduled_tasks_admin_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
@@ -22,9 +26,13 @@ router.include_router(team_router)
router.include_router(home_material_router)
router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
router.include_router(credit_management_router)
router.include_router(llm_billing_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(admin_contact_router)
router.include_router(api_keys_admin_router)
router.include_router(api_model_pricings_admin_router)
router.include_router(vp_v3_quota_admin_router)
router.include_router(bank_admin_router)
router.include_router(scheduled_tasks_admin_router)
@@ -0,0 +1,421 @@
from __future__ import annotations
import json
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditBalanceSourceType,
CreditScope,
)
from app.enums.credit_product import CreditProductType
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.user import User
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductStatusUpdate, CreditProductUpdate
from app.schemas.credit_subscription import AdminOfflineSubscriptionCreate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.offline_subscription_service import create_offline_subscription_order
from app.services.credit.product_service import product_to_dict
from app.services.credit.query_service import apply_balance_status_filter, effective_balance_status, get_balance_summary
from app.services.credit.time_policy import add_natural_months, last_usable_at
from app.services.credit.utils import utc_now
from app.services.notification import create_notification
from app.services.operation_log import log_operation
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/credit-management", tags=["admin-credit-management"])
def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
product.renewal_enabled = False
if product.validity_months is None:
product.validity_months = 1
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
product.monthly_grant_credits = None
product.first_purchase_price = None
product.regular_price = None
product.activity_price = None
product.activity_start_at = None
product.activity_end_at = None
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"首购价格": product.first_purchase_price,
"常规价格": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
raise HTTPException(status_code=400, detail=f"订阅套餐缺少字段:{''.join(missing)}")
if product.activity_price is None:
if product.activity_start_at is not None or product.activity_end_at is not None:
raise HTTPException(status_code=400, detail="未配置活动价时不能单独配置活动周期")
elif product.activity_start_at is None or product.activity_end_at is None:
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
elif product.activity_end_at <= product.activity_start_at:
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
elif product.product_type == CreditProductType.CREDIT_ADDON.value:
if product.grant_credits is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
raise HTTPException(status_code=400, detail="不支持的积分商品类型")
@router.get("/products")
async def list_products(
product_type: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(CreditProduct)
if product_type:
stmt = stmt.where(CreditProduct.product_type == product_type)
result = await db.execute(
stmt.order_by(CreditProduct.deleted_at.asc(), CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id)
)
return [product_to_dict(item) for item in result.scalars().all()]
@router.post("/products")
async def create_product(
data: CreditProductCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
exists = await db.execute(
select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1)
)
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已被使用,商品编码永久唯一且不可复用")
product = CreditProduct(id=generate_id())
_apply_product_payload(product, data.model_dump())
_validate_product_entity(product)
db.add(product)
await db.flush()
snapshot = product_to_dict(product)
await log_operation(
db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST",
"/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED",
user_id=admin.id, message="积分商品创建成功", detail={"product_id": product.id},
)
await db.commit()
return snapshot
@router.put("/products/{product_id}")
async def update_product(
product_id: str,
data: CreditProductUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能恢复或继续编辑")
before = product_to_dict(product)
_apply_product_payload(product, data.model_dump(exclude_unset=True))
_validate_product_entity(product)
await db.flush()
after = product_to_dict(product)
await log_operation(
db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str),
)
await db.commit()
return after
@router.put("/products/{product_id}/renewal")
async def update_product_renewal(
product_id: str,
data: CreditProductRenewalUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="商品已软删除,不能修改续费开关")
if product.product_type not in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}:
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
after = product_to_dict(product)
await log_operation(
db, admin.id, admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分套餐续费 {product.name}",
"PUT", f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps({"before": before, "after": bool(product.renewal_enabled)}, ensure_ascii=False),
)
log_operation_event(
domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_RENEWAL_UPDATED",
user_id=admin.id, message="积分套餐续费开关已更新",
detail={"product_id": product.id, "renewal_enabled": bool(product.renewal_enabled)},
)
await db.commit()
return after
@router.put("/products/{product_id}/status")
async def update_product_status(
product_id: str,
data: CreditProductStatusUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is not None:
raise HTTPException(status_code=409, detail="已软删除商品不能重新上架")
product.is_active = bool(data.is_active)
await db.flush()
await log_operation(
db, admin.id, admin.username,
f"{'上架' if product.is_active else '下架'}积分商品 {product.name}", "PUT",
f"/admin/credit-management/products/{product_id}/status",
)
await db.commit()
return product_to_dict(product)
@router.delete("/products/{product_id}")
async def soft_delete_product(
product_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.deleted_at is None:
product.is_active = False
product.deleted_at = utc_now()
await db.flush()
await log_operation(
db, admin.id, admin.username, f"软删除积分商品 {product.name}", "DELETE",
f"/admin/credit-management/products/{product_id}",
)
await db.commit()
return {"ok": True, "message": "商品已软删除,商品编码永久保留且不能恢复"}
@router.post("/users/{user_id}/offline-subscriptions")
async def create_offline_subscription(
user_id: str,
data: AdminOfflineSubscriptionCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
order = await create_offline_subscription_order(
db,
target_user_id=user_id,
product_id=data.product_id,
operator_admin_id=admin.id,
payment_method=data.payment_method,
quantity=data.quantity,
actual_paid_amount=data.actual_paid_amount,
offline_trade_no=data.offline_trade_no,
offline_payment_detail=data.offline_payment_detail,
remark=data.remark,
)
order_no = str(order.order_no)
subscription_id = order.subscription_id
amount = float(order.amount)
await log_operation(
db, admin.id, admin.username, f"为用户 {user_id} 创建线下真实订阅成交", "POST",
f"/admin/credit-management/users/{user_id}/offline-subscriptions",
detail=json.dumps({"order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}, ensure_ascii=False),
)
await db.commit()
return {"ok": True, "order_no": order_no, "subscription_id": subscription_id, "actual_paid_amount": amount}
except Exception:
await db.rollback()
raise
@router.get("/users/{user_id}/subscriptions")
async def list_user_subscriptions(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.user_id == user_id)
.order_by(UserCreditSubscription.created_at.desc(), UserCreditSubscription.id.desc())
)
return [
{
"id": item.id,
"product_name": item.product_name_snapshot,
"product_type": item.product_type_snapshot,
"product_type_label": "团队订阅套餐" if item.product_type_snapshot == "team_subscription" else "个人订阅套餐",
"team_id": item.team_id,
"status": item.status,
"status_label": {"active": "有效", "expired": "已过期", "cancelled": "已取消", "pending": "待生效"}.get(item.status, "其他状态"),
"quantity": item.quantity_snapshot,
"monthly_total_credits": float(item.monthly_total_credits_snapshot),
"paid_amount": float(item.paid_amount_snapshot),
"start_at": item.start_at,
"expires_at": item.expires_at,
}
for item in result.scalars().all()
]
@router.get("/users/{user_id}/summary")
async def get_user_credit_summary(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
return (await get_balance_summary(db, user_id)).to_dict()
@router.get("/users/{user_id}/balances")
async def list_user_credit_balances(
user_id: str,
status: str | None = Query(default=None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
result = await db.execute(
stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.offset((page - 1) * page_size).limit(page_size)
)
return [
{
"id": item.id,
"credit_scope": item.credit_scope,
"credit_scope_label": CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
"team_id": item.team_id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
"source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
"source_id": item.source_id,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
"consumed_amount": float(item.consumed_amount),
"expired_amount": float(item.expired_amount),
"revoked_amount": float(item.revoked_amount),
"valid_from": item.valid_from,
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, "其他状态"),
}
for item in result.scalars().all()
]
@router.post("/users/{user_id}/grant")
async def admin_grant_credit(
user_id: str,
data: AdminCreditGrantRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
starts_at = data.valid_from or utc_now()
ends_at = (
starts_at + timedelta(days=data.validity_value)
if data.validity_unit == "day"
else add_natural_months(starts_at, data.validity_value)
)
result = await grant_credits(
db, user_id=user_id, amount=data.amount, description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value, source_id=admin.id,
valid_from=starts_at, expires_at=ends_at, credit_level=data.credit_level,
related_id=admin.id, biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(db, user_id, "积分变动通知", f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
return {"ok": True, "credits": result.balance_after}
@router.post("/users/{user_id}/deduct")
async def admin_deduct_credit(
user_id: str,
data: AdminCreditDeductRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
result = await deduct_credits(
db, user_id=user_id, amount=data.amount, description=data.description,
related_id=admin.id, biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
allowed_scopes={CreditScope.PERSONAL.value},
)
except Exception as exc:
if exc.__class__.__name__ == "InsufficientCreditsError":
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
raise
await create_notification(db, user_id, "积分变动通知", f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit")
await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
return {"ok": True, "credits": result.balance_after}
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.llm_billing import LLM_BILLING_SCENE_LABELS
from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.models.user import User
from app.schemas.llm_billing import LlmBillingPolicyCreate, LlmBillingPolicyUpdate
from app.services.llm_billing.query_service import list_executions_with_calls
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
def _policy_out(policy: LlmBillingPolicyModel) -> dict:
# 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。
return {
"id": policy.id,
"scene_code": policy.scene_code,
"scene_name": policy.scene_name,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
"version": policy.version,
"created_by": policy.created_by,
"updated_by": policy.updated_by,
"created_at": policy.created_at,
"updated_at": policy.updated_at,
}
@router.get("/policies")
async def list_policies(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
return [_policy_out(item) for item in result.scalars().all()]
@router.post("/policies")
async def create_policy(
data: LlmBillingPolicyCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if data.scene_code not in LLM_BILLING_SCENE_LABELS:
raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
existing = await db.execute(
select(LlmBillingPolicyModel.id)
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
.limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
policy = LlmBillingPolicyModel(
id=generate_id(),
scene_code=data.scene_code,
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
# 保留历史物理字段,不做无业务价值的数据库重命名迁移。
pre_deduct_credits=data.charge_credits,
is_active=data.is_active,
version=1,
created_by=admin.id,
updated_by=admin.id,
)
db.add(policy)
await db.flush()
policy_id = policy.id
scene_code = policy.scene_code
charge_credits = float(policy.pre_deduct_credits)
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_CREATED",
user_id=admin.id,
detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id},
)
return _policy_out(policy)
@router.put("/policies/{policy_id}")
async def update_policy(
policy_id: str,
data: LlmBillingPolicyUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.id == policy_id)
.limit(1)
.with_for_update()
)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(status_code=404, detail="LLM积分场景不存在")
payload = data.model_dump(exclude_unset=True)
payload.pop("scene_name", None)
if "charge_credits" in payload:
policy.pre_deduct_credits = payload.pop("charge_credits")
if "is_active" in payload:
policy.is_active = payload["is_active"]
policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
policy.version += 1
policy.updated_by = admin.id
await db.flush()
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_UPDATED",
user_id=admin.id,
detail={
"scene_code": policy.scene_code,
"policy_id": policy.id,
"version": policy.version,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
},
)
return _policy_out(policy)
@router.get("/executions")
async def list_executions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
scene_code: str | None = Query(default=None),
status: str | None = Query(default=None),
user_id: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_executions_with_calls(
db,
page=page,
page_size=page_size,
scene_code=scene_code,
status=status,
user_id=user_id,
)
return {"items": items, "total": total}
+13 -134
View File
@@ -1,148 +1,27 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
from app.dependencies import get_admin_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.schemas.recharge_package import (
RechargePackageCreate,
RechargePackageUpdate,
RechargePackageOut,
)
from app.services.operation_log import log_operation
from app.utils.id_gen import generate_id
from app.services.credit.product_service import product_to_dict
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("", response_model=list[RechargePackageOut])
@router.get("")
async def admin_list_packages(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
return [_to_out(p) for p in result.scalars().all()]
@router.post("", response_model=RechargePackageOut)
async def create_package(
data: RechargePackageCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
pkg = RechargePackage(id=generate_id(), **data.model_dump())
db.add(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建充值套餐 {pkg.name}",
"POST",
"/admin/recharge-packages",
detail=json.dumps(
{
"id": pkg.id,
"name": pkg.name,
"price": round(pkg.price, 2),
"credits": round(pkg.credits, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
},
ensure_ascii=False,
),
)
await db.commit()
return _to_out(pkg)
@router.put("/{pkg_id}", response_model=RechargePackageOut)
async def update_package(
pkg_id: str,
data: RechargePackageUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
before = _to_out(pkg)
for k, v in data.model_dump(exclude_unset=True).items():
setattr(pkg, k, v)
await db.flush()
after = _to_out(pkg)
await log_operation(
db,
admin.id,
admin.username,
f"更新充值套餐 {pkg.name}",
"PUT",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"before": before,
"after": after,
},
ensure_ascii=False,
),
)
await db.commit()
return _to_out(pkg)
@router.delete("/{pkg_id}")
async def delete_package(
pkg_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
pkg_name = pkg.name
await db.delete(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除充值套餐 {pkg_name}",
"DELETE",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"name": pkg_name,
},
ensure_ascii=False,
),
)
await db.commit()
return {"ok": True}
return [product_to_dict(item) for item in result.scalars().all()]
+86 -40
View File
@@ -3,54 +3,57 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Body, Depends, Query
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
from app.schemas.team_manager import SetManagerRequest
from app.services.credit.team_subscription_service import (
list_member_period_usage,
list_team_subscriptions_for_management,
)
from app.services.operation_log import log_operation
from app.services.team_manager_service import set_team_manager
from app.services.team_manager_service import get_manager_history, set_team_manager
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
"""构造返回团队详情,包含 manager_name。"""
payload = {
"id": team.id,
"name": team.name,
"code": getattr(team, "code", None),
"description": getattr(team, "description", None),
"status": getattr(team, "status", "active"),
"sort_order": getattr(team, "sort_order", 0) or 0,
"member_count": 0,
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": getattr(team, "manager_id", None),
"manager_name": None,
}
# 查询成员数和管理人用户名
from sqlalchemy import func
from app.enums.user import UserType
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
payload["member_count"] = int(member_count)
if getattr(team, "manager_id", None):
mgr = await db.execute(
manager_name = None
if team.manager_id:
manager_name = (await db.execute(
select(User.username).where(User.id == team.manager_id).limit(1)
)
payload["manager_name"] = mgr.scalar_one_or_none()
return payload
)).scalar_one_or_none()
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"sort_order": team.sort_order or 0,
"member_count": int(member_count),
"created_at": team.created_at,
"updated_at": team.updated_at,
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
@router.get("", response_model=TeamListOut)
@@ -62,6 +65,7 @@ async def list_admin_teams(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
@@ -71,10 +75,11 @@ async def list_admin_team_options(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_options(db, include_disabled=include_disabled)
@router.post("", )
@router.post("")
async def create_admin_team(
req: TeamCreate,
admin: User = Depends(get_admin_user),
@@ -93,7 +98,22 @@ async def create_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}", )
@router.get("/{team_id}")
async def get_admin_team(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
result = await db.execute(select(Team).where(Team.id == team_id, Team.deleted_at.is_(None)).limit(1))
team = result.scalar_one_or_none()
if not team:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="团队不存在")
return await _team_detail_payload(db, team)
@router.put("/{team_id}")
async def update_admin_team(
team_id: str,
req: TeamUpdate,
@@ -113,7 +133,7 @@ async def update_admin_team(
return await _team_detail_payload(db, team)
@router.put("/{team_id}/manager", )
@router.put("/{team_id}/manager")
async def set_team_manager_endpoint(
team_id: str,
req: SetManagerRequest = Body(...),
@@ -121,19 +141,14 @@ async def set_team_manager_endpoint(
db: AsyncSession = Depends(get_db),
):
team = await set_team_manager(db, team_id, req.user_id)
manager_name = None
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
if req.user_id:
mgr = await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)
manager_name = mgr.scalar_one_or_none()
team_name = team.name
manager_name = (await db.execute(
select(User.username).where(User.id == req.user_id).limit(1)
)).scalar_one_or_none()
await log_operation(
db,
admin.id,
admin.username,
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
f"更换团队队长 {team.name}: {manager_name or req.user_id}",
"PUT",
f"/admin/teams/{team_id}/manager",
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
@@ -141,6 +156,37 @@ async def set_team_manager_endpoint(
return await _team_detail_payload(db, team)
@router.get("/{team_id}/subscriptions")
async def list_admin_team_subscriptions(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_team_subscriptions_for_management(db, team_id=team_id)
@router.get("/{team_id}/member-usage")
async def list_admin_team_member_usage(
team_id: str,
subscription_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await list_member_period_usage(db, team_id=team_id, subscription_id=subscription_id)
@router.get("/{team_id}/manager-history")
async def list_admin_team_manager_history(
team_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
del admin
return await get_manager_history(db, team_id)
@router.delete("/{team_id}")
async def delete_admin_team(
team_id: str,
@@ -157,4 +203,4 @@ async def delete_admin_team(
f"/admin/teams/{team_id}",
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
)
return {"message": "ok"}
return {"message": "团队已删除"}
+8
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.bank import router as bank_router
from app.api.v1.projects import router as projects_router
from app.api.v1.generation import router as generation_router
from app.api.v1.credits import router as credits_router
@@ -12,6 +13,7 @@ from app.api.v1.sms import router as sms_router
from app.api.v1.industries import router as industries_router
from app.api.v1.menu_configs import router as menu_configs_router
from app.api.v1.recharge_packages import router as recharge_packages_router
from app.api.v1.credit_products import router as credit_products_router
from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
@@ -35,9 +37,12 @@ from app.api.v1.material_admin import router as material_admin_router
from app.api.v1.private_portrait import router as private_portrait_router
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
from app.api.v1.upload_resource import router as upload_resource_router
from app.api.v1.invoices import router as invoices_router
from app.api.v1.invoice_headers import router as invoice_headers_router
api_router = APIRouter()
api_router.include_router(auth_router)
api_router.include_router(bank_router)
api_router.include_router(projects_router)
api_router.include_router(generation_router)
api_router.include_router(credits_router)
@@ -49,6 +54,7 @@ api_router.include_router(sms_router)
api_router.include_router(industries_router)
api_router.include_router(menu_configs_router)
api_router.include_router(recharge_packages_router)
api_router.include_router(credit_products_router)
api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
@@ -72,3 +78,5 @@ api_router.include_router(material_admin_router)
api_router.include_router(private_portrait_router)
api_router.include_router(private_portrait_virtual_router)
api_router.include_router(upload_resource_router)
api_router.include_router(invoices_router)
api_router.include_router(invoice_headers_router)
+374 -148
View File
@@ -24,6 +24,8 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.common import PAYMENT_ORDER_SOURCE_LABELS
from app.schemas.payment import PAYMENT_METHOD_LABELS, PAYMENT_STATUS_LABELS, FULFILLMENT_STATUS_LABELS
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
@@ -48,18 +50,21 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_balance_summary, get_user_credit_summary_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.schemas.invoice import InvoiceStatusUpdateRequest
from app.utils.id_gen import generate_id
@@ -130,6 +135,10 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_summary_map = await get_user_credit_summary_map(db, user_ids)
for item in users:
summary = credit_summary_map.get(item.id)
attach_credit_snapshot(item, summary.available_credits if summary else 0)
return {
"items": [
AdminUserOut.model_validate(user)
@@ -137,6 +146,9 @@ async def list_users(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary_map[user.id].personal_credits) if user.id in credit_summary_map else 0.0,
"team_available_credits": float(credit_summary_map[user.id].team_available_credits) if user.id in credit_summary_map else 0.0,
"team_frozen_credits": float(credit_summary_map[user.id].team_frozen_credits) if user.id in credit_summary_map else 0.0,
}
)
.model_dump(mode="json")
@@ -178,16 +190,29 @@ async def create_user(
hashed_password=hash_password(req.password),
email=req.email,
phone=req.phone,
credits=req.credits,
is_admin=req.is_admin if req.user_type == "admin" else False,
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_asset_limit=req.private_portrait_asset_limit,
)
user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
if req.credits > 0:
now = utc_now()
await add_credits(
db, user.id, req.credits, "管理员创建用户初始积分",
record_meta=build_admin_adjust_meta(),
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-create-user-credit:{user.id}",
)
else:
attach_credit_snapshot(user, 0)
await log_operation(
db,
admin.id,
@@ -257,13 +282,17 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.credits = round(user.credits, 2)
credit_summary = await get_balance_summary(db, user.id)
attach_credit_snapshot(user, credit_summary.available_credits)
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
"personal_credits": float(credit_summary.personal_credits),
"team_available_credits": float(credit_summary.team_available_credits),
"team_frozen_credits": float(credit_summary.team_frozen_credits),
}
)
@@ -276,9 +305,26 @@ async def adjust_credits(
db: AsyncSession = Depends(get_db),
):
if req.amount > 0:
await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
now = utc_now()
await add_credits(
db, user_id, req.amount, f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
valid_from=now, expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await deduct_credits(
db,
user_id,
abs(req.amount),
f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
allowed_scopes={"personal"},
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
@@ -369,6 +415,45 @@ async def update_user_admin_status(
return {"message": "ok"}
@router.put("/users/{user_id}/single-device-login-override")
async def update_single_device_login_override(
user_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""设置用户级单设备登录覆盖。
body.override: True=强制启用, False=强制禁用, None=跟随全局
"""
override = body.get("override")
if override is not None:
override = bool(override)
# None 表示跟随全局
await db.execute(
update(User).where(User.id == user_id).values(single_device_login_override=override)
)
await db.flush()
label = "跟随全局" if override is None else ("强制启用" if override else "强制禁用")
await log_operation(
db,
admin.id,
admin.username,
f"单设备登录设置: {label}",
"PUT",
f"/admin/users/{user_id}/single-device-login-override",
detail=json.dumps(
{
"user_id": user_id,
"override": override,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind(
user_id: str,
@@ -499,6 +584,7 @@ async def list_credit_records(
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
subscription_no: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
@@ -523,6 +609,7 @@ async def list_credit_records(
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
subscription_no=subscription_no,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
@@ -737,107 +824,112 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard with filters."""
from sqlalchemy import func
# Ensure by_status has all expected statuses with defaults
"""支付统计:线上、后台线下和总真实收入可分别统计。"""
del admin
by_status = {
"pending": {"count": 0, "amount": 0.0},
"paid": {"count": 0, "amount": 0.0},
"cancelled": {"count": 0, "amount": 0.0},
"refunded": {"count": 0, "amount": 0.0},
"pending": {"label": "待支付", "count": 0, "amount": 0.0},
"paid": {"label": "已支付", "count": 0, "amount": 0.0},
"cancelled": {"label": "已取消", "count": 0, "amount": 0.0},
"expired": {"label": "已过期", "count": 0, "amount": 0.0},
"failed": {"label": "失败", "count": 0, "amount": 0.0},
"refunded": {"label": "已退款", "count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
now_cst = datetime.now(CST)
today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST) if start_date else today_start
query_end = (
(datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
if end_date else today_end
)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
filters = [PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
filters.append(PaymentOrder.status == status)
# Status breakdown
status_result = await db.execute(
select(
PaymentOrder.status,
func.count().label("count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
)
.where(*breakdown_filters)
select(PaymentOrder.status, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
if row.status in by_status:
if row.status not in by_status:
by_status[row.status] = {
"count": row.count,
"amount": round(float(row.amount), 2)
"label": PAYMENT_STATUS_LABELS.get(row.status, "其他状态"),
"count": 0,
"amount": 0.0,
}
else:
# Map any unexpected status to cancelled
by_status["cancelled"]["count"] += row.count
by_status["cancelled"]["amount"] += round(float(row.amount), 2)
by_status[row.status]["count"] = int(row.count or 0)
by_status[row.status]["amount"] = round(float(row.amount or 0), 2)
# Today's stats (CST time zone) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
source_filters = [PaymentOrder.status == "paid", PaymentOrder.created_at >= query_start, PaymentOrder.created_at < query_end]
if payment_method:
source_filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
source_filters.append(PaymentOrder.order_source == order_source)
source_result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(*source_filters)
.group_by(PaymentOrder.order_source)
)
today_row = today_result.one()
by_source = {
"online_payment": {"label": "线上支付", "count": 0, "amount": 0.0},
"admin_offline": {"label": "后台线下成交", "count": 0, "amount": 0.0},
}
for row in source_result.all():
target = by_source.setdefault(
row.order_source,
{"label": PAYMENT_ORDER_SOURCE_LABELS.get(row.order_source, "其他订单来源"), "count": 0, "amount": 0.0},
)
target["count"] = int(row.count or 0)
target["amount"] = round(float(row.amount or 0), 2)
total_income = {
"label": "总真实收入",
"count": sum(int(item["count"]) for item in by_source.values()),
"amount": round(sum(float(item["amount"]) for item in by_source.values()), 2),
}
async def _period_income(start_at: datetime, end_at: datetime) -> dict:
result = await db.execute(
select(PaymentOrder.order_source, func.count().label("count"), func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"))
.where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= start_at,
PaymentOrder.paid_at < end_at,
)
.group_by(PaymentOrder.order_source)
)
source_map = {row.order_source: (int(row.count or 0), round(float(row.amount or 0), 2)) for row in result.all()}
online_count, online_amount = source_map.get("online_payment", (0, 0.0))
offline_count, offline_amount = source_map.get("admin_offline", (0, 0.0))
return {
"paid_count": online_count + offline_count,
"paid_amount": round(online_amount + offline_amount, 2),
"online_paid_count": online_count,
"online_paid_amount": online_amount,
"offline_paid_count": offline_count,
"offline_paid_amount": offline_amount,
}
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_result = await db.execute(
select(
func.count().label("paid_count"),
func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"paid_amount": round(float(today_row.paid_amount), 2),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"by_source": by_source,
"total_income": total_income,
"today": await _period_income(today_start, today_end),
"month": await _period_income(month_start, month_end),
}
@@ -846,6 +938,7 @@ async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
order_source: str | None = Query(None, pattern="^(online_payment|admin_offline)$"),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
@@ -853,13 +946,15 @@ async def list_payment_orders(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
"""管理后台统一订单列表;线上和后台线下成交均来自 payment_orders"""
del admin
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
count_query = select(func.count(PaymentOrder.id)).join(User, PaymentOrder.user_id == User.id)
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if order_source:
filters.append(PaymentOrder.order_source == order_source)
if status:
filters.append(PaymentOrder.status == status)
if phone:
@@ -868,43 +963,68 @@ async def list_payment_orders(
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
if filters:
query = query.where(*filters)
count_query = count_query.where(*filters)
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
}
for o, username, user_phone in rows
]
total = int((await db.execute(count_query)).scalar() or 0)
rows = (await db.execute(
query.order_by(PaymentOrder.created_at.desc(), PaymentOrder.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)).all()
items = []
for order, username, user_phone in rows:
payment_label = PAYMENT_METHOD_LABELS.get(order.payment_method, "其他支付方式")
if order.payment_method == "other" and order.offline_payment_detail:
payment_label = f"其他-线下收款({order.offline_payment_detail}"
items.append(
{
"id": order.id,
"orderNo": order.order_no,
"order_no": order.order_no,
"userId": order.user_id,
"user_id": order.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(order.amount), 2),
"credits": round(float(order.credits), 2),
"quantity": int(order.quantity or 1),
"quoted_unit_price_snapshot": float(order.quoted_unit_price_snapshot) if order.quoted_unit_price_snapshot is not None else None,
"quoted_amount_snapshot": float(order.quoted_amount_snapshot) if order.quoted_amount_snapshot is not None else None,
"actual_unit_price_snapshot": float(order.actual_unit_price_snapshot) if order.actual_unit_price_snapshot is not None else None,
"paymentMethod": order.payment_method,
"payment_method": order.payment_method,
"payment_method_label": payment_label,
"order_source": order.order_source,
"order_source_label": PAYMENT_ORDER_SOURCE_LABELS.get(order.order_source, "其他订单来源"),
"status": order.status,
"status_label": PAYMENT_STATUS_LABELS.get(order.status, "其他状态"),
"product_id": order.product_id,
"product_type": order.product_type,
"product_name_snapshot": order.product_name_snapshot,
"team_id_snapshot": order.team_id_snapshot,
"fulfillment_status": order.fulfillment_status,
"fulfillment_status_label": FULFILLMENT_STATUS_LABELS.get(order.fulfillment_status, "其他履约状态") if order.fulfillment_status else None,
"tradeNo": order.trade_no,
"trade_no": order.trade_no,
"offline_trade_no": order.offline_trade_no,
"offline_payment_detail": order.offline_payment_detail,
"remark": order.remark,
"refund_amount": float(order.refund_amount) if order.refund_amount is not None else None,
"refund_trade_no": order.refund_trade_no,
"refund_entitlement_status": order.refund_entitlement_status,
"paidAt": order.paid_at.isoformat() if order.paid_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"refunded_at": order.refunded_at.isoformat() if order.refunded_at else None,
"createdAt": order.created_at.isoformat() if order.created_at else None,
"created_at": order.created_at.isoformat() if order.created_at else None,
}
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -955,25 +1075,14 @@ async def refund_payment_order(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
"""保留退款 API 路由兼容旧客户端,但本版本明确不开放主动订单退款。"""
del admin
exists = (await db.execute(
select(PaymentOrder.id).where(PaymentOrder.order_no == order_no).limit(1)
)).scalar_one_or_none()
if not exists:
raise HTTPException(status_code=404, detail="订单不存在")
raise HTTPException(status_code=409, detail="当前版本暂未开放订单退款")
# ── Industry Config ──────────────────────────────────────
@@ -1526,6 +1635,8 @@ async def create_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
@@ -1554,6 +1665,8 @@ async def update_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
@@ -1629,10 +1742,6 @@ async def create_system_config(
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
try:
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
@@ -1667,10 +1776,6 @@ async def update_system_config(
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
try:
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value)
await db.flush()
await log_operation(
@@ -1973,11 +2078,10 @@ async def get_stats(
)
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
# 把 timestamptz 按东八区(业务时区)偏移后再转 DATE
# 直接手动 +8 小时再 CAST 成日期,简单稳妥,不依赖数据库时区名配置
# 与代码中 CST = timezone(timedelta(hours=8)) 保持一致。
# created_at 为 timestamptz,数据库 session 时区已是东八区(CST
# 读取出来的时间值即为北京时间,直接 CAST 成日期即可,无需再 +8 小时
from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at + timedelta(hours=8), Date)
_day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end
_chart_start_dt = datetime(
@@ -2210,6 +2314,8 @@ async def admin_list_generation_records(
status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
@@ -2232,6 +2338,10 @@ async def admin_list_generation_records(
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
query = query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
query = query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
# Count total
count_query = (
@@ -2247,6 +2357,10 @@ async def admin_list_generation_records(
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
if start_date:
count_query = count_query.where(GenerationRecord.created_at >= datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST))
if end_date:
count_query = count_query.where(GenerationRecord.created_at < (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=CST))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
@@ -2487,6 +2601,118 @@ async def upload_login_video(
return {"url": url}
# ── Payment Stats ────────────────────────────────────────
# ── Invoice Management ───────────────────────────────────
@router.get("/invoices")
async def admin_list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台发票列表(分页+筛选)。"""
from app.services.invoice import get_admin_invoices
items, total = await get_admin_invoices(
db, page, page_size,
status_filter=status,
phone=phone,
start_date=start_date,
end_date=end_date,
)
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/invoices/{invoice_id}")
async def admin_get_invoice(
invoice_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""后台获取发票详情(含关联订单)。"""
from app.services.invoice import get_invoice_with_orders
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=404, detail="发票不存在")
invoice = detail["invoice"]
orders = detail["orders"]
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"userId": invoice.user_id,
"headerType": invoice.header_type,
"headerName": invoice.header_name,
"headerTaxNo": invoice.header_tax_no,
"headerRegisterAddress": invoice.header_register_address,
"headerRegisterPhone": invoice.header_register_phone,
"headerBankName": invoice.header_bank_name,
"headerBankAccount": invoice.header_bank_account,
"email": invoice.email,
"totalAmount": round(float(invoice.total_amount), 2),
"totalCredits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
"createdAt": invoice.created_at.isoformat() if invoice.created_at else None,
"updatedAt": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"orderNo": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
@router.put("/invoices/{invoice_id}/status")
async def admin_update_invoice_status(
invoice_id: str,
req: InvoiceStatusUpdateRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票状态(success/failed)。"""
from app.services.invoice import update_invoice_status
invoice, old_status = await update_invoice_status(db, invoice_id, req, admin.id)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"发票状态变更: {invoice.invoice_no} {old_status}{req.status}",
"PUT",
f"/admin/invoices/{invoice_id}/status",
detail=json.dumps(
{
"invoice_id": invoice_id,
"invoice_no": invoice.invoice_no,
"old_status": old_status,
"new_status": req.status,
"failure_reason": req.failure_reason,
},
ensure_ascii=False,
),
)
await db.commit()
return {
"id": invoice.id,
"invoiceNo": invoice.invoice_no,
"status": invoice.status,
"failureReason": invoice.failure_reason,
"issuedAt": invoice.issued_at.isoformat() if invoice.issued_at else None,
}
+88 -43
View File
@@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,8 +11,9 @@ from app.dependencies import (
get_current_user,
get_current_user_allow_password_pending,
get_db,
security,
)
from app.models.credit_record import CreditRecord
from fastapi.security import HTTPAuthorizationCredentials
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
@@ -32,8 +33,13 @@ from app.services.auth import (
hash_password,
verify_password,
)
from app.utils.device import detect_device_type
from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.ledger_service import grant_credits
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -56,9 +62,16 @@ def _validate_captcha_if_needed(captcha_token: str | None) -> None:
)
def _token_response(user: User, remember_me: bool = False) -> dict:
def _token_response(user: User, remember_me: bool = False, device_type: str = "pc") -> dict:
user.credits = round(user.credits, 2)
token = create_access_token(user.id, remember_me)
# 按设备类型递增对应版本号 — 单设备登录(同端互斥)
if device_type == "mobile":
user.mobile_token_version = (user.mobile_token_version or 0) + 1
version = user.mobile_token_version
else:
user.pc_token_version = (user.pc_token_version or 0) + 1
version = user.pc_token_version
token = create_access_token(user.id, remember_me, version, device_type)
return {
"access_token": token,
"token_type": "bearer",
@@ -93,50 +106,57 @@ async def _get_register_credits(db: AsyncSession) -> int:
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
if credits <= 0:
attach_credit_snapshot(user, 0)
return
record = CreditRecord(
id=generate_id(),
now = datetime.now(timezone.utc)
result = await grant_credits(
db,
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"注册赠送 {credits} 积分",
source_type=CreditBalanceSourceType.REGISTER_GIFT.value,
source_id=user.id,
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"register-gift:{user.id}",
request_time=now,
)
db.add(record)
attach_credit_snapshot(user, result.balance_after)
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
enabled_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
)
enabled = enabled_result.scalar_one_or_none() == "true"
if not enabled:
if enabled_result.scalar_one_or_none() != "true":
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return
credits_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits").limit(1)
)
credits = int(credits_result.scalar_one_or_none() or "0")
if credits <= 0:
return
today = datetime.now(CST).date()
if user.last_login_at:
last_login_date = user.last_login_at.date()
if last_login_date >= today:
return
user.credits += credits
record = CreditRecord(
id=generate_id(),
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"每日登录赠送 {credits} 积分",
)
db.add(record)
now_cst = datetime.now(CST)
if credits > 0:
next_midnight_cst = datetime.combine(now_cst.date() + timedelta(days=1), datetime.min.time(), tzinfo=CST)
result = await grant_credits(
db,
user_id=user.id,
amount=credits,
description=f"每日登录赠送 {credits} 积分",
source_type=CreditBalanceSourceType.DAILY_LOGIN.value,
source_id=now_cst.date().isoformat(),
valid_from=now_cst,
expires_at=next_midnight_cst,
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"daily-login:{user.id}:{now_cst.date().isoformat()}",
request_time=now_cst,
)
attach_credit_snapshot(user, result.balance_after)
else:
attach_credit_snapshot(user, await get_available_credits(db, user.id))
@router.post(
@@ -144,7 +164,7 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
summary="客户端密码登录",
description="保留原有用户名/手机号 + 密码登录。仅允许 frontend 用户登录;管理员仍使用 /auth/admin-login。",
)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
_validate_captcha_if_needed(req.captcha_token)
user = await authenticate_user(db, req.username, req.password)
@@ -164,7 +184,8 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
return _token_response(user, req.remember_me, device_type)
@router.post(
@@ -172,7 +193,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端短信验证码登录",
description="新增兼容登录方式:手机号 + 短信验证码登录。不覆盖 /auth/login 密码登录。仅允许 frontend 用户登录。",
)
async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
async def sms_login(req: SmsLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
ok = await verify_sms_code(req.phone, req.code, "login")
if not ok:
raise HTTPException(
@@ -195,7 +216,8 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now(CST)
await db.flush()
return _token_response(user, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
return _token_response(user, req.remember_me, device_type)
@router.post(
@@ -203,7 +225,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
summary="客户端手机号短信注册",
description="手机号 + 注册短信验证码注册。注册成功后 username 默认等于手机号,不生成密码;前端需根据 must_set_password 引导用户设置密码。",
)
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
async def register(req: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
ok = await verify_sms_code(req.phone, req.code, "register")
if not ok:
raise HTTPException(
@@ -219,14 +241,13 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
)
register_credits = await _get_register_credits(db)
user = User(
id=generate_id(),
username=req.phone,
phone=req.phone,
hashed_password=hash_password(req.password),
password_set_at=datetime.now(CST),
credits=register_credits,
is_admin=False,
user_type="frontend",
)
@@ -237,7 +258,15 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
await _assign_default_frontend_menus(db, user)
user.credits = round(user.credits, 2)
token = create_access_token(user.id)
# 按设备类型递增对应版本号
device_type = detect_device_type(request.headers.get("user-agent"))
if device_type == "mobile":
user.mobile_token_version = (user.mobile_token_version or 0) + 1
version = user.mobile_token_version
else:
user.pc_token_version = (user.pc_token_version or 0) + 1
version = user.pc_token_version
token = create_access_token(user.id, False, version, device_type)
return {
"access_token": token,
"token_type": "bearer",
@@ -247,7 +276,22 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout")
async def logout(current_user: User = Depends(get_current_user_allow_password_pending)):
async def logout(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
current_user: User = Depends(get_current_user_allow_password_pending),
db: AsyncSession = Depends(get_db),
):
# 按设备类型递增对应版本号 — 使当前 token 失效
device_type = "pc"
if credentials:
payload = decode_access_token(credentials.credentials)
if payload:
device_type = payload.get("dev", "pc")
if device_type == "mobile":
current_user.mobile_token_version = (current_user.mobile_token_version or 0) + 1
else:
current_user.pc_token_version = (current_user.pc_token_version or 0) + 1
await db.flush()
return {"message": "ok"}
@@ -373,7 +417,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
@router.post("/admin-login")
async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
async def admin_login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
"""Admin-only login endpoint."""
user = await authenticate_user(db, req.username, req.password)
if not user:
@@ -391,6 +435,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
user.last_login_at = datetime.now(CST)
await db.flush()
token = create_access_token(user.id, req.remember_me)
device_type = detect_device_type(request.headers.get("user-agent"))
token = create_access_token(user.id, req.remember_me, 0, device_type)
user.credits = round(user.credits, 2)
return {"access_token": token, "token_type": "bearer", "user": UserOut.model_validate(user)}
+83
View File
@@ -0,0 +1,83 @@
"""银行账户前端查询接口。"""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.bank_account import BankAccount
from app.models.user import User
router = APIRouter(prefix="/bank", tags=["bank"])
@router.get("/default-account")
async def get_default_account(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取默认银行账户信息(前端展示用)。
优先返回标记为默认的启用账户;如果没有默认账户,返回第一个启用账户。
"""
# 先查默认账户
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
BankAccount.is_default.is_(True),
)
)
account = result.scalar_one_or_none()
# 没有默认账户则取第一个启用账户
if account is None:
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
).order_by(BankAccount.created_at.asc())
)
account = result.scalar_one_or_none()
if account is None:
return {
"has_account": False,
"account": None,
}
return {
"has_account": True,
"account": {
"id": account.id,
"accountName": account.account_name,
"bankName": account.bank_name,
"accountNo": account.account_no,
"description": account.description,
},
}
@router.get("/accounts")
async def list_active_accounts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取所有启用的银行账户列表(前端展示用)。"""
result = await db.execute(
select(BankAccount).where(
BankAccount.is_active.is_(True),
).order_by(BankAccount.is_default.desc(), BankAccount.created_at.asc())
)
accounts = result.scalars().all()
return {
"items": [
{
"id": a.id,
"accountName": a.account_name,
"bankName": a.bank_name,
"accountNo": a.account_no,
"isDefault": a.is_default,
"description": a.description,
}
for a in accounts
]
}
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.credit_product import CreditProductCatalogOut
from app.services.credit.product_service import build_product_catalog
router = APIRouter(prefix="/credit-products", tags=["credit-products"])
@router.get("/catalog", response_model=CreditProductCatalogOut)
async def get_credit_product_catalog(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await build_product_catalog(db, user=current_user)
+144 -22
View File
@@ -1,20 +1,115 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CREDIT_SCOPE_LABELS,
CreditScope,
)
from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS
from app.models.credit_ratio import CreditRatio
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.credit import CreditRecordOut
from app.schemas.credit_balance import CreditBalanceItemOut
from app.schemas.credit_ratio import CreditRatioOut
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.utils import utc_now
from app.services.credit.time_policy import last_usable_at
from app.services.credit_ratio_service import list_all_credit_ratios
from app.services.credits import get_records
router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_scope=item.credit_scope,
credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"),
team_id=item.team_id,
credit_level=item.credit_level,
credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"),
source_type=item.source_type,
source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"),
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
subscription_id=item.subscription_id,
subscription_period_id=item.subscription_period_id,
grant_amount=float(item.grant_amount),
unspent_amount=float(item.unspent_amount),
consumed_amount=float(item.consumed_amount),
expired_amount=float(item.expired_amount),
revoked_amount=float(item.revoked_amount),
valid_from=item.valid_from,
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
status_label=CREDIT_BALANCE_STATUS_LABELS.get(
effective_balance_status(item, request_time=checked_at), "其他状态"
),
created_at=item.created_at,
)
async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]:
if not records:
return []
ids = [item.id for item in records]
result = await db.execute(
select(
CreditRecordAllocation.credit_record_id,
CreditRecordAllocation.credit_scope_snapshot,
func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"),
)
.where(CreditRecordAllocation.credit_record_id.in_(ids))
.group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot)
)
scope_map: dict[str, dict[str, float]] = {}
for row in result.all():
scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0)
output = []
for record in records:
parts = scope_map.get(record.id, {})
sign = -1.0 if float(record.amount or 0) < 0 else 1.0
output.append({
"id": record.id,
"type": record.type,
"type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"),
"amount": float(record.amount),
"personal_amount": sign * float(parts.get("personal", 0)),
"team_amount": sign * float(parts.get("team", 0)),
"balance_delta": float(record.balance_delta or 0),
"expired_amount": float(record.expired_amount or 0),
"balance_after": float(record.balance_after or 0),
"description": record.description,
"billing_scene": record.billing_scene,
"billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None,
"scene_name_snapshot": record.scene_name_snapshot,
"input_tokens": record.input_tokens,
"output_tokens": record.output_tokens,
"total_tokens": record.total_tokens,
"llm_call_count": record.llm_call_count,
"llm_success_call_count": record.llm_success_call_count,
"llm_failed_call_count": record.llm_failed_call_count,
"created_at": record.created_at,
})
return output
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -23,18 +118,52 @@ async def get_credits(
db: AsyncSession = Depends(get_db),
):
records, total = await get_records(db, current_user.id, page, page_size)
summary = await get_balance_summary(db, current_user.id)
totals_result = await db.execute(
select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0),
).where(CreditRecord.user_id == current_user.id)
)
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
"credits": round(current_user.credits, 2),
"records": [CreditRecordOut.model_validate(r) for r in records],
**summary.to_dict(),
"records": await _records_with_scope_amounts(db, records),
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
"total_refunded": float(total_refunded or 0),
"total_expired": float(total_expired or 0),
}
@router.get("/balances")
async def list_credit_balances(
status: str | None = Query(default=None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
# 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长,
# 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。
stmt = select(UserCreditBalance).where(
UserCreditBalance.user_id == current_user.id,
UserCreditBalance.credit_scope == CreditScope.PERSONAL.value,
)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()]
@router.get(
"/credit-ratios",
response_model=list[CreditRatioOut],
summary="获取积分比例列表",
description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
)
async def list_client_credit_ratios(
current_user: User = Depends(get_current_user),
@@ -59,29 +188,22 @@ async def get_credit_ratios(
if ratios:
return [CreditRatioOut.model_validate(r) for r in ratios]
return []
video_engines_result = await db.execute(
video_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
)
video_engine_ids = video_engines_result.scalars().all()
image_engines_result = await db.execute(
image_result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
image_engine_ids = image_engines_result.scalars().all()
grouped = {}
video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all()))
image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all()))
if video_ratios:
grouped["video"] = video_ratios
image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
if image_ratios:
grouped["image"] = image_ratios
return grouped
@@ -17,7 +17,6 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
@@ -36,7 +35,6 @@ from app.schemas.hot_opening_replicate import (
)
from app.services.hot_opening_replicate_service import (
_get_project_for_user,
create_hot_opening_project,
delete_hot_opening_project,
generate_image_from_prompt,
generate_video_from_prompt,
@@ -66,8 +64,8 @@ from app.services.module_async_recovery_service import (
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -181,12 +179,7 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
description_prefix=(
"爆款开头复刻图片AI提词优化"
if is_image
else "爆款开头复刻视频提词优化"
@@ -0,0 +1,96 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.schemas.invoice import InvoiceHeaderCreate, InvoiceHeaderOut, InvoiceHeaderUpdate
from app.services.invoice_header import (
create_header,
delete_header,
get_user_headers,
set_default_header,
update_header,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoice-headers", tags=["invoice-headers"])
def _header_to_out(header) -> dict:
return {
"id": header.id,
"user_id": header.user_id,
"type": header.type,
"name": header.name,
"tax_no": header.tax_no,
"register_address": header.register_address,
"register_phone": header.register_phone,
"bank_name": header.bank_name,
"bank_account": header.bank_account,
"email": header.email,
"is_default": header.is_default,
"created_at": header.created_at.isoformat() if header.created_at else None,
"updated_at": header.updated_at.isoformat() if header.updated_at else None,
}
@router.post("", response_model=InvoiceHeaderOut)
async def create_invoice_header(
req: InvoiceHeaderCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票抬头。"""
header = await create_header(db, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.get("")
async def list_invoice_headers(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的所有发票抬头。"""
headers = await get_user_headers(db, current_user.id)
return {"items": [_header_to_out(h) for h in headers]}
@router.put("/{header_id}", response_model=InvoiceHeaderOut)
async def update_invoice_header(
header_id: str,
req: InvoiceHeaderUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新发票抬头。"""
header = await update_header(db, header_id, current_user.id, req)
await db.commit()
return _header_to_out(header)
@router.delete("/{header_id}")
async def delete_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""删除发票抬头。"""
await delete_header(db, header_id, current_user.id)
await db.commit()
return {"success": True}
@router.put("/{header_id}/set-default", response_model=InvoiceHeaderOut)
async def set_default_invoice_header(
header_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""设置默认发票抬头。"""
header = await set_default_header(db, header_id, current_user.id)
await db.commit()
return _header_to_out(header)
+110
View File
@@ -0,0 +1,110 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.invoice import Invoice, InvoiceOrder
from app.models.user import User
from app.schemas.invoice import InvoiceCreateRequest, InvoiceOut, InvoiceOrderOut
from app.services.invoice import (
create_invoice,
get_user_invoices,
get_invoice_by_id,
get_invoice_with_orders,
)
logger = logging.getLogger("videogen")
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.post("", response_model=InvoiceOut)
async def create_invoice_endpoint(
req: InvoiceCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""创建发票申请。"""
invoice = await create_invoice(db, current_user.id, req)
await db.commit()
# 重新查询以获取关联订单
detail = await get_invoice_with_orders(db, invoice.id)
return _invoice_to_out(detail["invoice"], detail["orders"])
@router.get("")
async def list_invoices(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户的发票列表(分页)。"""
invoices, total = await get_user_invoices(db, current_user.id, page, page_size)
# 加载每个发票的关联订单
items = []
for inv in invoices:
result = await db.execute(
select(InvoiceOrder).where(InvoiceOrder.invoice_id == inv.id)
)
orders = result.scalars().all()
items.append(_invoice_to_out(inv, list(orders)))
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.get("/{invoice_id}")
async def get_invoice(
invoice_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取发票详情(含关联订单)。"""
detail = await get_invoice_with_orders(db, invoice_id)
if not detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在")
invoice = detail["invoice"]
if invoice.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该发票")
return _invoice_to_out(invoice, detail["orders"])
def _invoice_to_out(invoice: Invoice, orders: list[InvoiceOrder]) -> dict:
"""将 Invoice ORM 对象转换为响应 dict。"""
return {
"id": invoice.id,
"user_id": invoice.user_id,
"invoice_no": invoice.invoice_no,
"header_type": invoice.header_type,
"header_name": invoice.header_name,
"header_tax_no": invoice.header_tax_no,
"header_register_address": invoice.header_register_address,
"header_register_phone": invoice.header_register_phone,
"header_bank_name": invoice.header_bank_name,
"header_bank_account": invoice.header_bank_account,
"email": invoice.email,
"total_amount": round(float(invoice.total_amount), 2),
"total_credits": round(float(invoice.total_credits), 2),
"status": invoice.status,
"failure_reason": invoice.failure_reason,
"issued_at": invoice.issued_at.isoformat() if invoice.issued_at else None,
"created_at": invoice.created_at.isoformat() if invoice.created_at else None,
"updated_at": invoice.updated_at.isoformat() if invoice.updated_at else None,
"orders": [
{
"id": o.id,
"invoice_id": o.invoice_id,
"order_id": o.order_id,
"order_no": o.order_no,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
}
for o in orders
],
}
+173 -82
View File
@@ -9,17 +9,16 @@ logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.recharge_package import RechargePackage
from app.models.credit.product import CreditProduct
from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
create_recharge_order,
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
PaymentCloseResult,
_resolve_order_for_cancellation,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -57,27 +56,25 @@ async def recharge(
raise HTTPException(status_code=400, detail="该支付方式未启用")
result = await db.execute(
select(RechargePackage).where(
RechargePackage.id == req.plan,
RechargePackage.is_active == True,
)
.limit(1)
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=400, detail="无效的套餐")
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail="无效或已下架的积分商品")
try:
order = await create_recharge_order(
db,
current_user.id,
credits=pkg.credits,
price=pkg.price,
label=pkg.name,
bonus_credits=pkg.bonus_credits,
method=req.method,
product_id=product.id,
quantity=req.quantity,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return order
@@ -86,11 +83,11 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
# 读取微信支付回调数据
body_bytes = await request.body()
body_str = body_bytes.decode("utf-8")
# 获取配置
from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client
db_configs = await _get_payment_configs(db)
# 检查 mock 模式
if _is_mock_mode(db_configs):
try:
@@ -104,29 +101,29 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.exception(f"Mock WeChat callback error: {e}")
return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功
# 真实模式:使用 wechatpayv3 SDK 工具验证回调并解析数据
try:
from wechatpayv3.utils import (
rsa_verify, load_public_key, sha256, b64decode,
AESGCM, InvalidTag
)
mch_id = db_configs.get("payment_wechat_mch_id", "")
api_v3_key = db_configs.get("payment_wechat_api_v3_key", "")
public_key = db_configs.get("payment_wechat_public_key", "")
if not all([mch_id, api_v3_key]):
logger.error("WeChat payment config missing for callback")
return {"code": "SUCCESS", "message": "OK"}
# 从请求头获取必要信息(不区分大小写)
headers = {k.lower(): v for k, v in dict(request.headers).items()}
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "")
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
if not public_key:
logger.error("WeChat platform public key not configured, cannot verify callback signature")
@@ -137,7 +134,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
if not timestamp or not nonce or not signature:
logger.error("WeChat callback missing required signature headers")
return {"code": "FAIL", "message": "Missing signature headers"}
# 验证签名:使用平台公钥验证
try:
is_verified = rsa_verify(
@@ -153,27 +150,27 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
return {"code": "FAIL", "message": "Signature verification error"}
# 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
import json
body_data = json.loads(body_str) if body_str else {}
resource = body_data.get("resource", {})
if not resource:
logger.error("WeChat callback resource not found")
raise HTTPException(status_code=400, detail="数据格式错误")
# 验证加密算法(官方文档要求固定为 AEAD_AES_256_GCM
algorithm = resource.get("algorithm", "")
if algorithm != "AEAD_AES_256_GCM":
logger.error(f"WeChat callback unsupported algorithm: {algorithm}")
raise HTTPException(status_code=400, detail="不支持的加密算法")
ciphertext = resource.get("ciphertext", "")
associated_data = resource.get("associated_data", "")
nonce_str = resource.get("nonce", "")
# 参数验证
if not ciphertext:
logger.error("WeChat callback ciphertext is empty")
@@ -181,22 +178,22 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
if not nonce_str:
logger.error("WeChat callback nonce is empty")
raise HTTPException(status_code=400, detail="随机数为空")
# 使用 AES-GCM 解密(符合官方文档规范)
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
try:
# API v3 key 需要转换为字节串
api_v3_key_bytes = api_v3_key.encode('utf-8')
# ciphertext 是 Base64 编码的,需要解码
ciphertext_bytes = b64decode(ciphertext)
# nonce 直接使用字符串编码(官方文档方式)
nonce_bytes = nonce_str.encode('utf-8')
# associated_data 是字符串,直接编码
associated_data_bytes = associated_data.encode('utf-8') if associated_data else b''
aesgcm = AESGCM(api_v3_key_bytes)
decrypted_str = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes)
except InvalidTag:
@@ -205,53 +202,83 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.error(f"WeChat callback decryption failed: {e}")
raise HTTPException(status_code=400, detail="数据解密失败")
if not decrypted_str:
logger.error("WeChat callback decryption returned empty")
raise HTTPException(status_code=400, detail="数据解密失败")
decrypted_data = json.loads(decrypted_str)
event_type = body_data.get("event_type", "")
# 处理支付成功回调
if event_type == "TRANSACTION.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
transaction_id = decrypted_data.get("transaction_id", "")
amount_info = decrypted_data.get("amount", {})
total_amount = amount_info.get("total", 0) / 100 # 转换为元
if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
# 处理退款回调
try:
processed = await process_payment_success_by_order_no(
db, order_no, transaction_id, total_amount
)
if processed:
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
else:
logger.warning(
f"WeChat payment success not fulfilled locally: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
except Exception as e:
# 微信已经通过验签、解密并明确通知支付成功。
# 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS,
# 后续由 pending 主动查单重试或日志人工对账处理。
await db.rollback()
logger.exception(
f"WeChat payment fulfillment error: order_no={order_no}, "
f"transaction_id={transaction_id}, error={e}"
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调:本版本只记录渠道退款事实,不撤销订阅、Period、Seat、积分或首购资格。
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
refund_amount_info = decrypted_data.get("amount", {}) or {}
refund_amount = refund_amount_info.get("refund", 0) / 100
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order and order.status == "refunding":
if order:
# 幂等记录渠道退款事实:即便升级前本地已经写成 refunded,
# 也要补齐渠道退款号/金额/时间;本版本绝不触碰任何订阅或积分权益。
order.status = "refunded"
order.transaction_id = refund_id
if refund_id:
order.refund_trade_no = refund_id
if refund_amount > 0:
order.refund_amount = refund_amount
elif order.refund_amount is None:
order.refund_amount = order.amount
if order.refunded_at is None:
order.refunded_at = utc_now()
order.refund_entitlement_status = "record_only"
await db.commit()
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
f"WECHAT_REFUND_CALLBACK_RECORDED order_no={order_no} "
f"refund_id={refund_id} amount={refund_amount} entitlement=record_only"
)
return {"code": "SUCCESS", "message": "OK"}
except Exception as e:
logger.exception(f"WeChat callback processing error: {e}")
@@ -263,7 +290,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
form_data = await request.form()
data = dict(form_data)
logger.info(
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
f"data={data}"
@@ -283,7 +310,7 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None
if order_no:
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
@@ -294,17 +321,37 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def list_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
status_filter: str | None = Query(None, description="按状态筛选: pending/paid/refunded/failed/cancelled"),
start_date: str | None = Query(None, description="创建时间起始,格式 YYYY-MM-DD"),
end_date: str | None = Query(None, description="创建时间结束,格式 YYYY-MM-DD"),
invoice_mode: bool = Query(False, description="开票模式:仅返回已支付订单"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
from app.services.payment import _check_and_expire_order
from datetime import datetime, timezone, timedelta
count_query = select(func.count(PaymentOrder.id)).where(PaymentOrder.user_id == current_user.id)
# 构建筛选条件
conditions = [PaymentOrder.user_id == current_user.id]
if status_filter:
conditions.append(PaymentOrder.status == status_filter)
if invoice_mode:
conditions.append(PaymentOrder.status == "paid")
conditions.append(PaymentOrder.order_source == "online_payment")
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at >= start_dt)
if end_date:
end_dt = (datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=timezone.utc)
conditions.append(PaymentOrder.created_at < end_dt)
# 统计总数
count_query = select(func.count(PaymentOrder.id)).where(*conditions)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.where(*conditions)
.order_by(PaymentOrder.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
@@ -313,7 +360,37 @@ async def list_orders(
for o in orders:
await _check_and_expire_order(db, o)
return {"items": [PaymentOrderOut.model_validate(o) for o in orders], "total": total}
# 开票模式:附带订单占用状态
items = []
if invoice_mode:
# 收集当前页订单ID
order_ids = [o.id for o in orders]
# 查询这些订单是否已被占用
from app.models.invoice import Invoice, InvoiceOrder
occupied_map: dict[str, str] = {}
if order_ids:
occ_result = await db.execute(
select(InvoiceOrder.order_id, Invoice.invoice_no)
.join(Invoice, InvoiceOrder.invoice_id == Invoice.id)
.where(
InvoiceOrder.order_id.in_(order_ids),
Invoice.status.in_(["processing", "success"]),
)
)
for row in occ_result.all():
occupied_map[row.order_id] = row.invoice_no
for o in orders:
item = PaymentOrderOut.model_validate(o)
item_dict = item.model_dump()
item_dict["is_occupied"] = o.id in occupied_map
item_dict["occupied_by"] = occupied_map.get(o.id)
items.append(item_dict)
else:
for o in orders:
item = PaymentOrderOut.model_validate(o)
items.append(item.model_dump())
return {"items": items, "total": total}
@router.get("/orders/{order_no}", response_model=PaymentOrderOut)
@@ -350,28 +427,42 @@ async def cancel_order(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
).limit(1)
).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay or WeChat order, call close API first
# Resolve the authoritative gateway state before local cancellation.
# If WeChat explicitly reports ORDERPAID, the payment service queries only
# this order once and runs the normal atomic payment-success fulfillment.
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
elif order.payment_method == "wechat":
try:
from app.services.payment import _close_wechat_order
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
log_user_id = str(current_user.id)
payment_method = str(order.payment_method)
close_result = await _resolve_order_for_cancellation(db, order, db_configs)
if close_result == PaymentCloseResult.PAID:
logger.info(
f"ORDER_CANCEL_PAID_RECOVERED order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="订单已支付并完成支付处理,无法取消",
)
if close_result != PaymentCloseResult.CLOSED:
logger.warning(
f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="支付渠道暂未确认订单关闭,请稍后重试",
)
order.status = "cancelled"
await db.flush()
logger.info(
+26 -23
View File
@@ -1,39 +1,42 @@
"""Legacy endpoint retained for old clients; only active credit add-ons are returned."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.dependencies import get_current_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.services.credit.product_service import product_to_dict
router = APIRouter(tags=["recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("/recharge-packages")
async def list_active_packages(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public: list active recharge packages."""
result = await db.execute(
select(RechargePackage)
.where(RechargePackage.is_active == True)
.order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
CreditProduct.deleted_at.is_(None),
)
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
return [_to_out(p) for p in result.scalars().all()]
items = []
for product in result.scalars().all():
payload = product_to_dict(product, user_price=product.price, price_type="regular", can_purchase=True)
payload.update(
{
"credits": payload["grant_credits"],
"bonus_credits": 0.0,
"total_credits": payload["grant_credits"],
"package_type": "credit_addon",
"is_gift": False,
}
)
items.append(payload)
return items
+3 -10
View File
@@ -20,7 +20,6 @@ from app.enums.credit_record import (
CreditRecordOwnerType,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -235,12 +234,7 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
description_prefix=(
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
),
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
@@ -277,8 +271,7 @@ def _analysis_dispatch_billing_context(
source_step_id=owner_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=owner_id,
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix=(
description_prefix=(
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
),
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
@@ -539,7 +532,7 @@ async def create_shot_task_set(
task_set, created_new = await create_task_set(db, current_user=current_user, req=req)
task_set_id = str(task_set.id)
if not created_new:
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
# 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
await db.rollback()
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
+265 -221
View File
@@ -1,45 +1,74 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
import csv
import io
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.config import settings
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.enums.team import TEAM_STATUS_LABELS, TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
from app.schemas.team_join_request import (
JoinByCodeRequest,
JoinRequestHandle,
JoinRequestOut,
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.schemas.team_join_request import JoinByCodeRequest, JoinRequestHandle, JoinRequestOut, JoinTeamInfoOut
from app.schemas.team_manager import SetManagerRequest
from app.schemas.team_subscription import TeamSeatCreateRequest, TeamSeatUpdateRequest
from app.services import team_invitation_service
from app.services.credit.team_subscription_service import (
cancel_seat,
create_seat,
list_member_period_usage,
list_team_subscriptions_for_management,
update_seat,
)
from app.services.team_credit_record_service import list_team_credit_records as query_team_credit_records
from app.services.team_manager_service import (
get_managed_team,
get_manager_history,
get_team_members,
is_team_manager,
transfer_credits_to_member,
list_manager_access_teams,
set_team_manager,
)
router = APIRouter(prefix="/team", tags=["team"])
CST = timezone(timedelta(hours=8))
def _team_payload(team: Team, *, manager_name: str | None, member_count: int) -> dict:
status = team.status or TeamStatus.ACTIVE.value
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": status,
"status_label": TEAM_STATUS_LABELS.get(status, "其他状态"),
"is_read_only": status == TeamStatus.DISABLED.value,
"team_credit_frozen": status == TeamStatus.DISABLED.value,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": manager_name,
"first_subscription_paid_at": team.first_subscription_paid_at,
}
async def _resolve_flow_team_id(db: AsyncSession, *, current_user: User, team_id: str | None) -> str:
if team_id:
# 真正的当前/历史队长权限由流水 Service 根据 TeamManagerHistory 再校验。
return team_id
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=400, detail="请指定需要查看的历史团队")
return team.id
# ── 获取当前用户管理的团队 ──────────────────────────────
@router.get("/managed")
async def get_managed_team_info(
current_user: User = Depends(get_current_user),
@@ -47,30 +76,25 @@ async def get_managed_team_info(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=404, detail="您不是任何团队的管理人")
from sqlalchemy import func
from app.enums.user import UserType
raise HTTPException(status_code=404, detail="当前不是任何团队的队长")
member_count = (await db.execute(
select(func.count(User.id)).where(
User.user_type == UserType.FRONTEND.value,
User.team_id == team.id,
)
)).scalar() or 0
return _team_payload(team, manager_name=current_user.username, member_count=int(member_count))
return {
"id": team.id,
"name": team.name,
"code": team.code,
"description": team.description,
"status": team.status,
"member_count": int(member_count),
"manager_id": team.manager_id,
"manager_name": current_user.username,
}
@router.get("/manager-access")
async def list_manager_access(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""当前及历史队长可访问的团队列表,用于历史团队流水入口。"""
return await list_manager_access_teams(db, current_user.id)
# ── 团队成员列表 ──────────────────────────────────────
@router.get("/members")
async def list_team_members(
page: int = Query(1, ge=1),
@@ -80,31 +104,123 @@ async def list_team_members(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员列表")
return await get_team_members(db, team.id, page=page, page_size=page_size)
# ── 转账积分给成员 ────────────────────────────────────
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: ManagerTransferRequest,
@router.put("/manager")
async def transfer_manager(
req: SetManagerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以转让队长")
await set_team_manager(db, team.id, req.user_id)
return {"message": "团队队长已更换"}
@router.post("/members/{member_id}/credits")
async def transfer_credits(
member_id: str,
req: dict = Body(default_factory=dict),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
del member_id, req, current_user, db
raise HTTPException(status_code=409, detail="当前版本不支持团队积分转账,请使用团队订阅席位额度")
@router.get("/subscriptions")
async def list_team_subscriptions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
return await list_team_subscriptions_for_management(db, team_id=team.id)
@router.post("/subscriptions/{subscription_id}/seats")
async def create_subscription_seat(
subscription_id: str,
req: TeamSeatCreateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await create_seat(
db,
current_user.id,
req.target_user_id,
req.amount,
req.direction or "increase",
req.description,
team_id=team.id,
subscription_id=subscription_id,
manager_user_id=current_user.id,
user_id=req.user_id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "ok"}
return {"message": "席位已创建", "seat_id": seat.id}
# ── 邀请码管理 ────────────────────────────────────────
@router.post("/invitations", )
@router.put("/seats/{seat_id}")
async def update_subscription_seat(
seat_id: str,
req: TeamSeatUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
seat = await update_seat(
db,
team_id=team.id,
seat_id=seat_id,
manager_user_id=current_user.id,
monthly_allocated_credits=req.monthly_allocated_credits,
)
return {"message": "席位额度已更新", "seat_id": seat.id}
@router.delete("/seats/{seat_id}")
async def cancel_subscription_seat(
seat_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以管理团队订阅席位")
await cancel_seat(db, team_id=team.id, seat_id=seat_id, manager_user_id=current_user.id)
return {"message": "席位已取消"}
@router.get("/member-usage")
async def get_member_usage(
subscription_id: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看成员团队积分消耗")
return await list_member_period_usage(db, team_id=team.id, subscription_id=subscription_id)
@router.get("/manager-history")
async def manager_history(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有当前团队队长可以查看完整队长任期历史")
return await get_manager_history(db, team.id)
@router.post("/invitations")
async def create_invitation(
req: TeamInvitationCreate,
current_user: User = Depends(get_current_user),
@@ -112,15 +228,13 @@ async def create_invitation(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可创建邀请码")
raise HTTPException(status_code=403, detail="只有团队队长可创建邀请码")
expires_at = None
if req.expires_at:
try:
expires_at = datetime.fromisoformat(req.expires_at)
except ValueError:
raise HTTPException(status_code=400, detail="过期时间格式错误")
except ValueError as exc:
raise HTTPException(status_code=400, detail="过期时间格式错误") from exc
invitation = await team_invitation_service.create_invitation(
db, team.id, current_user.id, req.max_uses, expires_at
)
@@ -143,7 +257,7 @@ async def list_invitations(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看邀请码")
invitations = await team_invitation_service.get_invitations_for_team(db, team.id)
return [
{
@@ -167,10 +281,9 @@ async def revoke_invitation(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.revoke_invitation(db, invitation_id, current_user.id)
return {"message": "ok"}
return {"message": "邀请码已撤销"}
# ── 加入申请 ──────────────────────────────────────────
@router.post("/join")
async def join_by_code(
req: JoinByCodeRequest,
@@ -178,42 +291,43 @@ async def join_by_code(
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.create_join_request(db, current_user.id, req.invitation_code)
return {"message": "申请已提交,请等待团队管理人审批"}
return {"message": "申请已提交,请等待团队队长审批"}
@router.get("/join-info", )
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证邀请码并返回团队信息(用于加入页面展示)。"""
async def _join_info_payload(
db: AsyncSession,
*,
code: str,
current_user: User | None,
) -> JoinTeamInfoOut:
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return JoinTeamInfoOut(team_name="", team_id="", valid=False, already_in_team=False, has_pending_request=False)
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
team_result = await db.execute(
select(Team).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
already_in_team = current_user and current_user.team_id == invitation.team_id
team = team_result.scalar_one_or_none()
if not team or team.status != TeamStatus.ACTIVE.value:
return JoinTeamInfoOut(
team_name=team.name if team else "",
team_id=invitation.team_id,
valid=False,
already_in_team=False,
has_pending_request=False,
)
already_in_team = bool(current_user and current_user.team_id == invitation.team_id)
has_pending_request = False
if current_user:
from app.models.team_join_request import TeamJoinRequest
pending = await db.execute(
select(TeamJoinRequest).where(
select(TeamJoinRequest.id).where(
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.team_id == invitation.team_id,
TeamJoinRequest.status == "pending",
).limit(1)
)
has_pending = pending.scalar_one_or_none()
has_pending_request = has_pending is not None
has_pending_request = pending.scalar_one_or_none() is not None
return JoinTeamInfoOut(
team_name=team_name,
team_name=team.name,
team_id=invitation.team_id,
valid=True,
already_in_team=already_in_team,
@@ -221,31 +335,21 @@ async def get_join_info(
)
@router.get("/join-info/public", )
async def get_join_info_public(
@router.get("/join-info")
async def get_join_info(
code: str = Query(...),
current_user: User | None = Depends(get_optional_current_user),
db: AsyncSession = Depends(get_db),
):
"""公开接口:验证邀请码并返回团队信息(无需登录)。"""
invitation = await team_invitation_service.get_invitation_by_code(db, code)
if not invitation:
return {"team_name": "", "team_id": "", "valid": False, "already_in_team": False, "has_pending_request": False}
team = await db.execute(
select(Team.name).where(Team.id == invitation.team_id, Team.deleted_at.is_(None)).limit(1)
)
team_name = team.scalar_one_or_none() or ""
return {
"team_name": team_name,
"team_id": invitation.team_id,
"valid": True,
"already_in_team": False,
"has_pending_request": False,
}
return await _join_info_payload(db, code=code, current_user=current_user)
@router.get("/join-requests", )
@router.get("/join-info/public")
async def get_join_info_public(code: str = Query(...), db: AsyncSession = Depends(get_db)):
return await _join_info_payload(db, code=code, current_user=None)
@router.get("/join-requests")
async def list_join_requests(
status: str | None = Query(None),
current_user: User = Depends(get_current_user),
@@ -253,29 +357,22 @@ async def list_join_requests(
):
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
raise HTTPException(status_code=403, detail="只有团队队长可查看加入申请")
requests = await team_invitation_service.get_all_requests(db, team.id, status)
# 获取团队名
team_name_result = await db.execute(
select(Team.name).where(Team.id == team.id).limit(1)
)
team_name = team_name_result.scalar_one_or_none() or ""
return [
JoinRequestOut(
id=r["id"],
team_id=r["team_id"],
team_name=team_name,
user_id=r["user_id"],
username=r["username"],
phone=r.get("phone"),
status=r["status"],
note=r.get("note"),
created_at=r["created_at"],
handled_at=r.get("handled_at"),
id=item["id"],
team_id=item["team_id"],
team_name=team.name,
user_id=item["user_id"],
username=item["username"],
phone=item.get("phone"),
status=item["status"],
note=item.get("note"),
created_at=item["created_at"],
handled_at=item.get("handled_at"),
)
for r in requests
for item in requests
]
@@ -286,157 +383,104 @@ async def handle_join_request(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await team_invitation_service.handle_join_request(
db, request_id, current_user.id, req.action, req.note
)
return {"message": "ok"}
await team_invitation_service.handle_join_request(db, request_id, current_user.id, req.action, req.note)
return {"message": "申请已处理"}
# ── 团队积分变动记录 ────────────────────────────────────
@router.get("/credit-records")
async def list_team_credit_records(
team_id: str | None = Query(None, description="历史队长查看旧团队时传团队ID"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
phone: str | None = Query(None, description="按手机号搜索"),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
end_date: str | None = Query(None, description="截止日期 YYYY-MM-DD"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看团队所有成员的积分变动记录(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
# 如果传了 phone,先找到对应的 user_id
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
if not resolved_user_id:
return {"items": [], "total": 0, "summary": {}}
return await list_admin_credit_records(
return {"items": [], "total": 0, "page": page, "page_size": page_size}
return await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=page,
page_size=page_size,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# ── 团队积分导出 Excel ──────────────────────────────────
@router.get("/credit-records/export")
async def export_team_credit_records(
team_id: str | None = Query(None),
user_id: str | None = Query(None),
phone: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal)$", description="流水类型"),
subscription_id: str | None = Query(None),
record_type: str | None = Query(None, pattern="^(recharge|consume|refund|team_internal|expire|revoke)$"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""导出团队积分变动记录为 Excel(仅管理人)。"""
team = await get_managed_team(db, current_user.id)
if not team:
raise HTTPException(status_code=403, detail="只有团队管理人可查看")
from app.services.admin_credit_record_service import list_admin_credit_records
resolved_team_id = await _resolve_flow_team_id(db, current_user=current_user, team_id=team_id)
resolved_user_id = user_id
if phone and not user_id:
phone_result = await db.execute(
select(User.id).where(
User.team_id == team.id,
User.phone == phone,
User.is_active.is_(True),
).limit(1)
)
if phone and not resolved_user_id:
phone_result = await db.execute(select(User.id).where(User.phone == phone).limit(1))
resolved_user_id = phone_result.scalar_one_or_none()
# 拉取全部记录(不分页)
result = await list_admin_credit_records(
if not resolved_user_id:
# 导出筛选手机号不存在时必须返回空结果,不能因为 user_id=None 退化成导出整个团队流水。
resolved_user_id = "__not_found__"
result = await query_team_credit_records(
db,
team_id=resolved_team_id,
viewer_user_id=current_user.id,
page=1,
page_size=10000,
team_id=team.id,
user_id=resolved_user_id,
member_user_id=resolved_user_id,
subscription_id=subscription_id,
record_type=record_type,
start_date=start_date,
end_date=end_date,
)
# 生成 CSV(兼容 Excel 打开,UTF-8 BOM
import csv
import io
from datetime import datetime as _dt
def _format_dt(val):
if val is None:
return "-"
try:
# 情况 1:已经是 datetime
if isinstance(val, _dt):
dt = val
elif isinstance(val, (int, float)):
# 情况 2:Unix 时间戳(极少,兼容旧代码)
dt = _dt.fromtimestamp(val)
elif isinstance(val, str):
# 情况 3ISO 字符串(admin_credit_record_service._iso 返回的格式)
s = val.strip()
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = _dt.fromisoformat(s)
except ValueError:
# 兼容旧格式 YYYY-MM-DD HH:MM:SS
dt = _dt.strptime(s, "%Y-%m-%d %H:%M:%S")
else:
return str(val)
# 统一转东八区展示
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=CST)
else:
dt = dt.astimezone(CST)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception: # noqa: BLE001
return str(val) if val else "-"
team_result = await db.execute(select(Team).where(Team.id == resolved_team_id).limit(1))
team = team_result.scalar_one_or_none()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["用户名", "手机号", "类型", "积分变动", "余额", "说明", "时间"])
writer.writerow(["用户名", "流水类型", "团队积分变动", "说明", "订阅实例", "周期ID", "席位ID", "时间"])
for item in result.get("items", []):
created_at = item.get("created_at")
if isinstance(created_at, datetime):
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=CST)
else:
created_at = created_at.astimezone(CST)
created_at = created_at.strftime("%Y-%m-%d %H:%M:%S")
writer.writerow([
item.get("username") or "-",
item.get("phone") or "-",
item.get("record_type_label") or item.get("type") or "-",
item.get("amount", 0),
item.get("balance_after", 0),
item.get("record_type_label") or "-",
item.get("team_amount", 0),
item.get("description") or "-",
_format_dt(item.get("created_at")),
item.get("subscription_no") or "历史订阅",
item.get("subscription_period_id") or "-",
item.get("seat_id") or "-",
created_at or "-",
])
from starlette.responses import StreamingResponse
from urllib.parse import quote
filename = f"团队积分_{(team.name if team else resolved_team_id)}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
output.seek(0)
safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8-sig",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
iter(["\ufeff" + output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
_UNRESOLVED_HOLDS_SQL = text(
"""
SELECT
h.id,
h.user_id,
h.biz_key,
h.amount,
h.created_at
FROM credit_records AS h
WHERE h.type = 'hold'
AND NOT EXISTS (
SELECT 1
FROM credit_records AS x
WHERE x.refund_for_biz_key = h.biz_key
OR x.biz_key IN (
REPLACE(h.biz_key, :hold_suffix, :hold_release_suffix),
REPLACE(h.biz_key, :hold_suffix, :charge_suffix)
)
)
ORDER BY h.created_at ASC
LIMIT 5000
"""
)
async def amain() -> int:
"""维护窗口检查脚本:列出旧 HOLD 未形成 RELEASE/CHARGE 的业务,禁止带病切换。"""
async with async_session() as db:
result = await db.execute(
_UNRESOLVED_HOLDS_SQL,
{
"hold_suffix": ":hold",
"hold_release_suffix": ":hold_release",
"charge_suffix": ":charge",
},
)
rows = result.mappings().all()
print(
json.dumps(
{
"checked_at": utc_now().isoformat(),
"unresolved_count": len(rows),
"items": [dict(row) for row in rows],
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from decimal import Decimal
from sqlalchemy import text
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.models.base import async_session
from app.services.credit.ledger_service import grant_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, utc_now
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="将 users.credits 一次性迁移到动态积分余额表。")
parser.add_argument("--batch-size", type=int, default=500)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--commit", action="store_true")
parser.add_argument("--after-id", default="")
return parser
async def amain(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
if args.dry_run and args.commit:
raise SystemExit("--dry-run 和 --commit 不能同时使用")
do_commit = bool(args.commit)
batch_size = max(1, min(int(args.batch_size or 500), 5000))
migration_time = utc_now()
cursor = str(args.after_id or "")
stats = {"positive_users": 0, "zero_users": 0, "negative_reset_users": 0, "migrated_credits": "0.00", "last_id": cursor}
total = Decimal("0.00")
while True:
async with async_session() as db:
rows = (await db.execute(
text("SELECT id, credits FROM users WHERE id > :cursor ORDER BY id ASC LIMIT :limit"),
{"cursor": cursor, "limit": batch_size},
)).mappings().all()
if not rows:
break
try:
for row in rows:
user_id = str(row["id"])
legacy = to_credit_decimal(row.get("credits") or 0)
cursor = user_id
stats["last_id"] = cursor
if legacy > 0:
stats["positive_users"] += 1
total += legacy
if do_commit:
await grant_credits(
db,
user_id=user_id,
amount=legacy,
description="历史用户积分迁移",
source_type=CreditBalanceSourceType.LEGACY_MIGRATION.value,
valid_from=migration_time,
expires_at=add_natural_months(migration_time, 12),
credit_level=CreditLevel.GENERAL.value,
source_id=user_id,
related_id=user_id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"legacy-user-credits:{user_id}",
metadata_json={"legacy_credits": str(legacy), "migration_time": migration_time.isoformat()},
request_time=migration_time,
)
elif legacy < 0:
stats["negative_reset_users"] += 1
else:
stats["zero_users"] += 1
if do_commit:
await db.commit()
else:
await db.rollback()
except Exception:
await db.rollback()
raise
stats["migrated_credits"] = str(total.quantize(Decimal("0.01")))
stats["migration_time"] = migration_time.isoformat()
stats["mode"] = "commit" if do_commit else "dry-run"
print(json.dumps(stats, ensure_ascii=False, indent=2))
return 0
def main() -> int:
return asyncio.run(amain(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
async with async_session() as db:
rows = (await db.execute(text("""
SELECT e.id, e.total_call_count, e.successful_call_count, e.failed_call_count,
e.total_input_tokens, e.total_output_tokens, e.total_tokens,
COUNT(a.id) AS actual_calls,
COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS actual_success,
COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0) AS actual_failed,
COALESCE(SUM(a.input_tokens), 0) AS actual_input,
COALESCE(SUM(a.output_tokens), 0) AS actual_output,
COALESCE(SUM(a.total_tokens), 0) AS actual_total
FROM llm_billing_executions e
LEFT JOIN llm_call_attempts a ON a.billing_execution_id = e.id
GROUP BY e.id
HAVING e.total_call_count <> COUNT(a.id)
OR e.successful_call_count <> COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0)
OR e.failed_call_count <> COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0)
OR e.total_tokens <> COALESCE(SUM(a.total_tokens), 0)
ORDER BY e.id
LIMIT 1000
"""))).mappings().all()
print(json.dumps({"checked_at": utc_now().isoformat(), "mismatch_count": len(rows), "items": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str))
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,39 @@
from __future__ import annotations
import asyncio
import json
from decimal import Decimal
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
checked_at = utc_now()
async with async_session() as db:
row = (await db.execute(text("""
SELECT
COALESCE(SUM(CASE WHEN credits > 0 THEN credits ELSE 0 END), 0) AS legacy_positive,
COALESCE((SELECT SUM(grant_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_grant,
COALESCE((SELECT SUM(unspent_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_unspent,
COALESCE((SELECT COUNT(*) FROM users WHERE credits < 0), 0) AS legacy_negative_users
FROM users
"""))).mappings().one()
legacy = Decimal(str(row["legacy_positive"] or 0)).quantize(Decimal("0.01"))
migrated = Decimal(str(row["migrated_grant"] or 0)).quantize(Decimal("0.01"))
output = {
"checked_at": checked_at.isoformat(),
"legacy_positive": str(legacy),
"migrated_grant": str(migrated),
"migrated_unspent": str(Decimal(str(row["migrated_unspent"] or 0)).quantize(Decimal("0.01"))),
"legacy_negative_users_reset_to_zero": int(row["legacy_negative_users"] or 0),
"matched": legacy == migrated,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0 if output["matched"] else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
+4
View File
@@ -131,6 +131,10 @@ class Settings(BaseSettings):
CAPTCHA_ENABLED: bool = True
# 银行交易查询接口配置
BANK_API_BASE: str = ""
BANK_API_KEY: str = ""
BASE_URL: str = ""
CORS_ORIGINS: list[str] = ["*"]
+35 -3
View File
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.user import User
from app.services.auth import decode_access_token, user_must_set_password
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.system_config_cache import get_system_config_value
security = HTTPBearer(auto_error=False)
@@ -32,15 +34,19 @@ async def get_current_user_allow_password_pending(
detail="未登录",
)
user_id = decode_access_token(credentials.credentials)
if not user_id:
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期",
)
user_id = payload.get("sub")
token_version = payload.get("ver", 0)
device_type = payload.get("dev", "pc")
# Skip captcha tokens
if user_id.startswith("captcha:"):
if user_id and user_id.startswith("captcha:"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的凭证",
@@ -53,6 +59,31 @@ async def get_current_user_allow_password_pending(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在或已禁用",
)
# 单设备登录校验 — 根据全局开关 + 用户级覆盖决定是否启用
override = getattr(user, "single_device_login_override", None)
if override is True:
enabled = True
elif override is False:
enabled = False
else:
# 跟随全局设置
config_val = await get_system_config_value(db, "single_device_login_enabled")
enabled = config_val is not None and config_val.lower() in ("true", "1", "yes")
if enabled:
# 按设备类型比对对应版本号
if device_type == "mobile":
current_version = user.mobile_token_version
else:
current_version = user.pc_token_version
if token_version != current_version:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号已在其他设备登录,请重新登录",
)
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
@@ -92,6 +123,7 @@ async def get_optional_current_user(
if user_must_set_password(user):
return None
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
+3
View File
@@ -11,6 +11,7 @@ class CeleryQueue(str, Enum):
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
GEN_SHOT_SPLIT = "gen_shot_split"
GEN_CREDIT_MAINTENANCE = "gen_credit_maintenance"
GEN_API_UPSCALE = "gen_api_upscale"
DEFAULT = "default"
@@ -45,6 +46,8 @@ class CeleryTaskName(str, Enum):
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
CREDIT_MAINTENANCE = "credit.maintenance_once"
PAYMENT_EXPIRE_PENDING = "payment.expire_pending_orders"
VP_V3_POLL_ASSET = "vp_v3.asset.poll_status"
VP_V3_SYNC_DUE_ASSETS = "vp_v3.sync_due_assets"
VP_V3_DELETE_ASSET = "vp_v3.asset.delete_remote"
+28
View File
@@ -133,3 +133,31 @@ class BillingBlockEventEnum(StrEnum):
INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS"
NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE"
class PaymentOrderSourceEnum(StrEnum):
"""订单来源。"""
ONLINE_PAYMENT = "online_payment"
ADMIN_OFFLINE = "admin_offline"
PAYMENT_ORDER_SOURCE_LABELS = {
PaymentOrderSourceEnum.ONLINE_PAYMENT.value: "线上支付",
PaymentOrderSourceEnum.ADMIN_OFFLINE.value: "后台线下成交",
}
class OfflinePaymentMethodEnum(StrEnum):
"""后台线下收款方式。"""
BANK_TRANSFER = "bank_transfer"
CASH = "cash"
OTHER = "other"
OFFLINE_PAYMENT_METHOD_LABELS = {
OfflinePaymentMethodEnum.BANK_TRANSFER.value: "银行转账",
OfflinePaymentMethodEnum.CASH.value: "现金",
OfflinePaymentMethodEnum.OTHER.value: "其他-线下收款",
}
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from enum import StrEnum
class CreditLevel(StrEnum):
PROMOTIONAL = "promotional"
GENERAL = "general"
CREDIT_LEVEL_SORT = {
CreditLevel.PROMOTIONAL.value: 10,
CreditLevel.GENERAL.value: 20,
}
CREDIT_LEVEL_LABELS = {
CreditLevel.PROMOTIONAL.value: "活动积分",
CreditLevel.GENERAL.value: "普通积分",
}
class CreditScope(StrEnum):
PERSONAL = "personal"
TEAM = "team"
CREDIT_SCOPE_LABELS = {
CreditScope.PERSONAL.value: "个人积分",
CreditScope.TEAM.value: "团队积分",
}
CREDIT_SCOPE_SORT = {
CreditScope.TEAM.value: 10,
CreditScope.PERSONAL.value: 20,
}
class CreditBalanceSourceType(StrEnum):
REGISTER_GIFT = "register_gift"
DAILY_LOGIN = "daily_login"
SIGN_IN = "sign_in"
ACTIVITY = "activity"
ADMIN_GRANT = "admin_grant"
SUBSCRIPTION_GRANT = "subscription_grant"
CREDIT_ADDON = "credit_addon"
LEGACY_MIGRATION = "legacy_migration"
BUSINESS_REFUND = "business_refund"
CREDIT_BALANCE_SOURCE_TYPE_LABELS = {
CreditBalanceSourceType.REGISTER_GIFT.value: "注册赠送",
CreditBalanceSourceType.DAILY_LOGIN.value: "每日登录赠送",
CreditBalanceSourceType.SIGN_IN.value: "签到赠送",
CreditBalanceSourceType.ACTIVITY.value: "活动赠送",
CreditBalanceSourceType.ADMIN_GRANT.value: "管理员赠送",
CreditBalanceSourceType.SUBSCRIPTION_GRANT.value: "订阅积分发放",
CreditBalanceSourceType.CREDIT_ADDON.value: "积分增值包",
CreditBalanceSourceType.LEGACY_MIGRATION.value: "历史积分迁移",
CreditBalanceSourceType.BUSINESS_REFUND.value: "业务退款",
}
class CreditBalanceStatus(StrEnum):
SCHEDULED = "scheduled"
ACTIVE = "active"
CONSUMED = "consumed"
EXPIRED = "expired"
REVOKED = "revoked"
CANCELLED = "cancelled"
CREDIT_BALANCE_STATUS_LABELS = {
CreditBalanceStatus.SCHEDULED.value: "待生效",
CreditBalanceStatus.ACTIVE.value: "有效",
CreditBalanceStatus.CONSUMED.value: "已用完",
CreditBalanceStatus.EXPIRED.value: "已过期",
CreditBalanceStatus.REVOKED.value: "已撤销",
CreditBalanceStatus.CANCELLED.value: "已取消",
}
class CreditAllocationAction(StrEnum):
GRANT = "grant"
CONSUME = "consume"
REFUND_AVAILABLE = "refund_available"
REFUND_EXPIRED = "refund_expired"
EXPIRE = "expire"
REVOKE = "revoke"
CREDIT_ALLOCATION_ACTION_LABELS = {
CreditAllocationAction.GRANT.value: "发放",
CreditAllocationAction.CONSUME.value: "消费",
CreditAllocationAction.REFUND_AVAILABLE.value: "有效积分退款",
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
CreditAllocationAction.EXPIRE.value: "积分过期",
CreditAllocationAction.REVOKE.value: "积分撤销",
}
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from enum import StrEnum
class CreditProductType(StrEnum):
SUBSCRIPTION = "subscription"
TEAM_SUBSCRIPTION = "team_subscription"
CREDIT_ADDON = "credit_addon"
CREDIT_PRODUCT_TYPE_LABELS = {
CreditProductType.SUBSCRIPTION.value: "个人订阅套餐",
CreditProductType.TEAM_SUBSCRIPTION.value: "团队订阅套餐",
CreditProductType.CREDIT_ADDON.value: "积分增值包",
}
class SubscriptionBillingCycle(StrEnum):
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
SUBSCRIPTION_BILLING_CYCLE_LABELS = {
SubscriptionBillingCycle.MONTHLY.value: "月卡",
SubscriptionBillingCycle.QUARTERLY.value: "季卡",
SubscriptionBillingCycle.YEARLY.value: "年卡",
}
SUBSCRIPTION_GRANT_COUNT = {
SubscriptionBillingCycle.MONTHLY.value: 1,
SubscriptionBillingCycle.QUARTERLY.value: 3,
SubscriptionBillingCycle.YEARLY.value: 12,
}
class SubscriptionTierCode(StrEnum):
STARTER = "starter"
STANDARD = "standard"
ADVANCED = "advanced"
SUPER = "super"
SUBSCRIPTION_TIER_LABELS = {
SubscriptionTierCode.STARTER.value: "入门",
SubscriptionTierCode.STANDARD.value: "标准",
SubscriptionTierCode.ADVANCED.value: "高级",
SubscriptionTierCode.SUPER.value: "超级",
}
class ProductPriceType(StrEnum):
FIRST_PURCHASE = "first_purchase"
REGULAR = "regular"
ACTIVITY = "activity"
PRODUCT_PRICE_TYPE_LABELS = {
ProductPriceType.FIRST_PURCHASE.value: "首购价",
ProductPriceType.REGULAR.value: "常规价",
ProductPriceType.ACTIVITY.value: "活动价",
}
+23 -4
View File
@@ -5,7 +5,9 @@ class CreditRecordType(str, Enum):
RECHARGE = "recharge"
CONSUME = "consume"
REFUND = "refund"
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
TEAM_INTERNAL = "team_internal" # 历史团队内部积分流转
EXPIRE = "expire"
REVOKE = "revoke"
class CreditRecordOwnerType(str, Enum):
@@ -56,6 +58,8 @@ class CreditRecordMediaType(str, Enum):
class CreditRecordAction(str, Enum):
CHARGE = "charge"
REFUND = "refund"
PRE_DEDUCT = "pre_deduct"
# 历史动作,仅用于展示旧流水,新代码不得继续创建。
HOLD = "hold"
HOLD_RELEASE = "hold_release"
@@ -111,15 +115,22 @@ class CreditRecordBillingScene(str, Enum):
ADMIN_ADJUST = "admin_adjust"
REFUND = "refund"
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
BUSINESS_FAILURE_REFUND_AVAILABLE = "business_failure_refund_available"
BUSINESS_FAILURE_REFUND_EXPIRED = "business_failure_refund_expired"
CREDIT_EXPIRE = "credit_expire"
CREDIT_REVOKE = "credit_revoke"
SUBSCRIPTION_GRANT = "subscription_grant"
CREDIT_ADDON_PURCHASE = "credit_addon_purchase"
UNKNOWN = "unknown"
CREDIT_RECORD_ACTION_LABELS = {
CreditRecordAction.CHARGE.value: "真实",
CreditRecordAction.CHARGE.value: "真实",
CreditRecordAction.PRE_DEDUCT.value: "历史固定预扣",
CreditRecordAction.REFUND.value: "真实退款",
CreditRecordAction.HOLD.value: "预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
CreditRecordAction.HOLD.value: "历史预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "历史预扣释放",
}
CREDIT_RECORD_TYPE_LABELS = {
@@ -127,6 +138,8 @@ CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.CONSUME.value: "消费",
CreditRecordType.REFUND.value: "回退",
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
CreditRecordType.EXPIRE.value: "过期",
CreditRecordType.REVOKE.value: "撤销",
}
CREDIT_RECORD_SUBJECT_LABELS = {
@@ -209,5 +222,11 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
CreditRecordBillingScene.REFUND.value: "回退",
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_AVAILABLE.value: "业务失败有效积分退款",
CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_EXPIRED.value: "业务失败过期积分退款",
CreditRecordBillingScene.CREDIT_EXPIRE.value: "积分过期",
CreditRecordBillingScene.CREDIT_REVOKE.value: "积分撤销",
CreditRecordBillingScene.SUBSCRIPTION_GRANT.value: "订阅积分发放",
CreditRecordBillingScene.CREDIT_ADDON_PURCHASE.value: "积分增值包购买",
CreditRecordBillingScene.UNKNOWN.value: "历史未知",
}
@@ -0,0 +1,33 @@
from __future__ import annotations
from enum import StrEnum
class CreditSubscriptionStatus(StrEnum):
PENDING = "pending"
ACTIVE = "active"
EXPIRED = "expired"
CANCELLED = "cancelled"
CREDIT_SUBSCRIPTION_STATUS_LABELS = {
CreditSubscriptionStatus.PENDING.value: "待生效",
CreditSubscriptionStatus.ACTIVE.value: "生效中",
CreditSubscriptionStatus.EXPIRED.value: "已到期",
CreditSubscriptionStatus.CANCELLED.value: "已取消",
}
class CreditSubscriptionPeriodStatus(StrEnum):
SCHEDULED = "scheduled"
GRANTED = "granted"
CANCELLED = "cancelled"
EXPIRED = "expired"
CREDIT_SUBSCRIPTION_PERIOD_STATUS_LABELS = {
CreditSubscriptionPeriodStatus.SCHEDULED.value: "待发放",
CreditSubscriptionPeriodStatus.GRANTED.value: "已发放",
CreditSubscriptionPeriodStatus.CANCELLED.value: "已取消",
CreditSubscriptionPeriodStatus.EXPIRED.value: "已到期",
}
+47 -33
View File
@@ -3,49 +3,55 @@ from __future__ import annotations
from enum import StrEnum
class LlmBillingConfigKey(StrEnum):
ENABLED = "llm_billing_enabled"
HOLD_DEFAULT = "llm_hold_credits_default"
HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt"
HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt"
HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt"
HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis"
LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits"
class LlmBillingLedgerState(StrEnum):
BILLING_BYPASSED = "billing_bypassed"
MISSING = "missing"
ACTIVE = "active"
RELEASED = "released"
CHARGED = "charged"
SUCCEEDED = "succeeded"
REFUNDED = "refunded"
FINAL_FAILED = "final_failed"
INVALID = "invalid"
BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费
RELEASED = "refunded" # 历史兼容别名
CHARGED = "active" # 当前 attempt 已完成真实消费,可继续执行
class LlmBillingExecutionStatus(StrEnum):
CHARGED = "charged"
PRE_DEDUCTED = "pre_deducted" # 历史兼容;新数据不得再创建
PROCESSING = "processing"
SUCCEEDED = "succeeded"
FINAL_FAILED = "final_failed"
REFUNDED = "refunded"
REFUND_FAILED = "refund_failed"
class LlmCallAttemptStatus(StrEnum):
STARTED = "started"
SUCCEEDED = "succeeded"
FAILED = "failed"
TIMEOUT = "timeout"
UNKNOWN = "unknown"
class LlmBillingEvent(StrEnum):
HOLD_START = "LLM_HOLD_START"
HOLD_SUCCESS = "LLM_HOLD_SUCCESS"
HOLD_BYPASSED = "LLM_HOLD_BYPASSED"
HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT"
HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID"
HOLD_MISSING = "LLM_HOLD_MISSING"
HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START"
HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS"
HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED"
FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START"
FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS"
FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED"
CHARGE_START = "LLM_CHARGE_START"
CHARGE_IDEMPOTENT_HIT = "LLM_CHARGE_IDEMPOTENT_HIT"
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
CHARGE_INSUFFICIENT = "LLM_CHARGE_INSUFFICIENT"
CHARGE_CONFIG_INVALID = "LLM_CHARGE_CONFIG_INVALID"
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
PROVIDER_START = "LLM_PROVIDER_START"
PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS"
PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE"
SETTLE_START = "LLM_SETTLE_START"
SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS"
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE"
SETTLE_FAILED = "LLM_SETTLE_FAILED"
POSTPROCESS_FAILURE = "LLM_POSTPROCESS_FAILURE"
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
BUSINESS_SUCCESS = "LLM_BUSINESS_SUCCESS"
FINAL_FAILURE_START = "LLM_FINAL_FAILURE_START"
FINAL_FAILURE_REFUND_SUCCESS = "LLM_FINAL_FAILURE_REFUND_SUCCESS"
FINAL_FAILURE_REFUND_FAILED = "LLM_FINAL_FAILURE_REFUND_FAILED"
CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START"
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
@@ -53,10 +59,18 @@ class LlmBillingEvent(StrEnum):
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
USAGE_INVALID = "LLM_USAGE_INVALID"
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
class LlmBillingDomain(StrEnum):
LLM_BILLING = "llm_billing"
LLM_BILLING_SCENE_LABELS = {
"generation_record_text_prompt_optimize": "AI创作-提示词优化",
"hot_opening_image_prompt_optimize": "爆款开头复刻-图片提示词优化",
"hot_opening_video_prompt_optimize": "爆款开头复刻-视频提示词优化",
"shot_image_prompt_optimize": "拆镜复刻-图片提示词优化",
"shot_video_prompt_optimize": "拆镜复刻-视频提示词优化",
"shot_original_video_analysis": "拆镜复刻-原视频AI分析",
"shot_segment_video_analysis": "拆镜复刻-片段视频AI分析",
}
+13
View File
@@ -25,5 +25,18 @@ TEAM_JOIN_REQUEST_STATUS_LABELS = {
}
class TeamSeatStatus(str, Enum):
ACTIVE = "active"
CANCELLED = "cancelled"
EXPIRED = "expired"
TEAM_SEAT_STATUS_LABELS = {
TeamSeatStatus.ACTIVE.value: "使用中",
TeamSeatStatus.CANCELLED.value: "已取消",
TeamSeatStatus.EXPIRED.value: "已到期",
}
# 前端筛选“未分配团队”时使用的稳定哨兵值,不与真实团队ID混用。
TEAM_UNASSIGNED_VALUE = "__none__"
+7 -3
View File
@@ -18,9 +18,13 @@ class UploadResourceModuleEnum(StrEnum):
class UploadResourceTypeEnum(StrEnum):
"""上传资源类型。"""
IMAGE = "Image"
VIDEO = "Video"
AUDIO = "Audio"
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
F_IMAGE = "Image"
F_VIDEO = "Video"
F_AUDIO = "Audio"
SHOT_SEGMENT = "shot_segment"
PDF = "pdf"
FILE = "file"
+9 -65
View File
@@ -32,29 +32,6 @@ async def lifespan(app: FastAPI):
await init_redis()
# await _seed_data()
# Background task: auto-expire pending payment orders and sync status
async def _order_expiry_loop():
from app.services.payment import expire_all_pending_orders, sync_pending_orders
from logging import getLogger
bg_logger = getLogger("payment")
while True:
try:
async with async_session() as db:
# 同步待支付订单状态(检查支付宝实际支付状态
sync_count = await sync_pending_orders(db)
if sync_count > 0:
bg_logger.info(f"Synced {sync_count} pending payment order(s)")
# 自动过期订单
n = await expire_all_pending_orders(db)
if n > 0:
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
except Exception as e:
bg_logger.error(f"Order expiry loop error: {e}")
await asyncio.sleep(60) # check every minute
expiry_task = asyncio.create_task(_order_expiry_loop())
# 启动token刷新定时任务(每5分钟检查一次,小于800秒有效期的token进行刷新)
from app.tasks.token_refresh_task import token_refresh_scheduler
token_refresh_task = asyncio.create_task(token_refresh_scheduler())
@@ -108,7 +85,6 @@ async def lifespan(app: FastAPI):
await consumption_queue_task
consumption_schedule_task.cancel()
pre_test_poll_task.cancel()
expiry_task.cancel()
token_refresh_task.cancel()
await close_database()
await close_redis()
@@ -138,7 +114,6 @@ async def _seed_data():
email="admin@videogen.ai",
phone="13800000000",
hashed_password=hash_password("123456"),
credits=10000,
is_admin=True,
user_type="admin",
)
@@ -154,7 +129,6 @@ async def _seed_data():
email="demo@videogen.ai",
phone="13888888888",
hashed_password=hash_password("123456"),
credits=2680,
is_admin=False,
user_type="frontend",
)
@@ -177,21 +151,12 @@ async def _seed_data():
("payment_alipay_enabled", "false", "支付宝启用"),
("payment_alipay_app_id", "", "支付宝AppID"),
("payment_alipay_private_key", "", "支付宝私钥"),
# Text credit config
("text_credits_per_1000_tokens", "1", "每1000 token消耗文本积分"),
# User credits config
("user_register_credits", "100", "用户注册赠送积分"),
("user_login_credits", "0", "用户每日登录赠送积分"),
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
# Operation manual
("operation_manual", "", "操作手册链接"),
("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"),
("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"),
("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"),
("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"),
("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"),
("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"),
]
for key, value, desc in configs:
existing = await db.execute(
@@ -439,7 +404,9 @@ async def _seed_data():
("/credit-records", "交易流水", "WalletOutlined", 2, None),
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
("/credit-products", "积分产品", "GiftOutlined", 4, None),
("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"),
("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"),
("/notifications", "消息推送", "BellOutlined", 5, None),
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
@@ -471,34 +438,7 @@ async def _seed_data():
logging.info("Default menu configs inserted successfully")
# Seed recharge packages
from app.models.recharge_package import RechargePackage
default_packages = [
("体验包", 500, 49, 0, "首次体验推荐", "normal", 0),
("进阶包", 2000, 168, 200, "最受欢迎", "normal", 1),
("专业包", 5000, 388, 500, "高性价比", "normal", 2),
("企业包", 20000, 1280, 2000, "团队首选", "normal", 3),
]
for name, credits, price, bonus, desc, ptype, order in default_packages:
existing = await db.execute(
select(RechargePackage).where(RechargePackage.name == name).limit(1)
)
if not existing.scalar_one_or_none():
db.add(
RechargePackage(
id=generate_id(),
name=name,
credits=credits,
price=price,
bonus_credits=bonus,
description=desc,
package_type=ptype,
is_gift=False,
is_active=True,
sort_order=order,
)
)
# 积分订阅套餐和增值包只允许管理后台人工录入;未配置时客户端不展示购买入口。
# Seed industry configs
from app.models.industry_config import IndustryConfig
@@ -635,7 +575,11 @@ def create_app() -> FastAPI:
async def v3_http_exception_handler(request: Request, exc: HTTPException):
"""仅对 /api/v3/ 路径返回统一格式,HTTP 状态码固定 200。"""
if not str(request.url.path).startswith("/api/v3"):
raise exc # 交给其他处理器
# 非 v3 路径返回标准 HTTPException 响应,保持原始状态码
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
detail = exc.detail
message = detail.get("message", str(detail)) if isinstance(detail, dict) else str(detail)
code_map = {400: 40000, 401: 40100, 403: 40300, 404: 40400, 429: 42900, 422: 42200, 500: 50000, 504: 50400}
@@ -3,7 +3,7 @@ import json
import logging
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from fastapi import Request, Response
from fastapi import HTTPException, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.config import settings
+18 -1
View File
@@ -1,6 +1,7 @@
from app.models.base import Base, TimestampMixin, SoftDeleteMixin, engine, async_session, init_database, close_database
from app.models.user import User
from app.models.team import Team
from app.models.team_manager_history import TeamManagerHistory
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.project import Project
@@ -17,6 +18,12 @@ from app.models.video_engine import VideoEngine
from app.models.credit_ratio import CreditRatio
from app.models.menu_config import MenuConfig
from app.models.recharge_package import RechargePackage
from app.models.credit import (
CreditProduct, CreditRecordAllocation, UserCreditBalance,
UserCreditSubscription, UserCreditSubscriptionPeriod,
TeamSubscriptionSeat, TeamSubscriptionSeatUsage,
)
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
@@ -36,6 +43,10 @@ from app.models.user_oauth_account import UserOAuthAccount
from app.models.user_oauth_app import UserOAuthApp
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
from app.models.contact_request import ContactRequest
from app.models.invoice import Invoice, InvoiceOrder
from app.models.invoice_header import InvoiceHeader
from app.models.bank_account import BankAccount
from app.models.scheduled_task import ScheduledTask
from app.models.private_portrait import PrivatePortraitProject, PrivatePortraitValidateSession, PrivatePortraitAssetGroup, PrivatePortraitAsset
from app.models.api import ApiKey, ApiGenerationTask, ApiUsageLog, ApiKeyUpscaleConfig, ApiUpscaleLink
@@ -45,7 +56,11 @@ __all__ = [
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat", "TeamSubscriptionSeatUsage", "TeamManagerHistory",
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
"OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
@@ -57,4 +72,6 @@ __all__ = [
"PrivatePortraitAssetGroup", "PrivatePortraitAsset",
"ApiKey", "ApiGenerationTask", "ApiUsageLog", "ApiKeyUpscaleConfig", "ApiUpscaleLink",
"ApiModelPricing",
"Invoice", "InvoiceOrder", "InvoiceHeader",
"BankAccount", "ScheduledTask",
]
+18
View File
@@ -0,0 +1,18 @@
"""银行账户信息模型。"""
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class BankAccount(Base, TimestampMixin):
__tablename__ = "bank_accounts"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
account_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="账户名称")
bank_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="开户银行")
account_no: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="银行账号")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
is_default: Mapped[bool] = mapped_column(Boolean, default=False, comment="是否默认账户")
description: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="备注")
@@ -0,0 +1,55 @@
"""银行交易流水模型。"""
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class BankTransaction(Base):
__tablename__ = "bank_transactions"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
# 账户信息
account_id: Mapped[str] = mapped_column(String(32), index=True, nullable=False, comment="银行账户ID")
account_no: Mapped[str] = mapped_column(String(64), index=True, nullable=False, comment="银行账号")
# 记录创建时间
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
comment="记录创建时间",
)
# 交易信息
transaction_no: Mapped[str | None] = mapped_column(String(128), unique=True, nullable=True, comment="交易流水号(唯一)")
transaction_time: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), index=True, nullable=True, comment="交易时间")
transaction_amount: Mapped[str] = mapped_column(String(32), nullable=False, comment="交易金额(字符串保持精度)")
balance_direction: Mapped[str | None] = mapped_column(String(8), nullable=True, comment="借贷方向: DR-借/出金, CR-贷/入金")
balance_after: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="交易后余额")
# 对方信息
counterparty_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="对方户名")
counterparty_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="对方账号")
counterparty_bank: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="对方开户行")
# 交易摘要
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment="摘要/备注")
digest_code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="摘要码")
purpose: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="用途")
# 原始数据
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True, comment="接口返回原始JSON")
# 同步信息
sync_batch: Mapped[str | None] = mapped_column(String(32), index=True, nullable=True, comment="同步批次号")
is_synced: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否同步成功")
__table_args__ = (
Index("ix_bank_transactions_account_time", "account_no", "transaction_time"),
Index("ix_bank_transactions_sync_batch", "sync_batch"),
)
@@ -0,0 +1,17 @@
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit.team_seat import TeamSubscriptionSeat
from app.models.credit.team_seat_usage import TeamSubscriptionSeatUsage
__all__ = [
"CreditRecordAllocation",
"UserCreditBalance",
"CreditProduct",
"UserCreditSubscription",
"UserCreditSubscriptionPeriod",
"TeamSubscriptionSeat",
"TeamSubscriptionSeatUsage",
]
@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditScope
from app.models.base import Base, TimestampMixin
class CreditRecordAllocation(Base, TimestampMixin):
__tablename__ = "credit_record_allocations"
__table_args__ = (
Index("ix_credit_record_allocations_record", "credit_record_id", "id"),
Index("ix_credit_record_allocations_balance", "credit_balance_id", "created_at"),
Index("ix_credit_record_allocations_user_time", "user_id", "created_at"),
Index("ix_credit_record_allocations_source_allocation", "source_allocation_id"),
Index("ix_credit_record_allocations_team_time", "team_id_snapshot", "created_at", "id"),
Index(
"ix_credit_record_allocations_team_manager_time",
"team_id_snapshot", "team_manager_id_snapshot", "created_at", "id",
),
Index(
"ix_credit_record_allocations_team_period_user",
"subscription_period_id_snapshot", "user_id", "allocation_action",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="CASCADE"), nullable=False
)
credit_balance_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
comment="真实业务消费者;发放类记录为资金所有人",
)
source_allocation_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
)
allocation_action: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
credit_scope_snapshot: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
subscription_period_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
seat_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
unspent_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditBalanceStatus, CreditScope
from app.models.base import Base, TimestampMixin
class UserCreditBalance(Base, TimestampMixin):
__tablename__ = "user_credit_balances"
__table_args__ = (
CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
CheckConstraint(
"grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
name="ck_user_credit_balances_amount_reconciled",
),
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
CheckConstraint(
"(credit_scope = 'personal' AND team_id IS NULL) OR "
"(credit_scope = 'team' AND team_id IS NOT NULL AND subscription_id IS NOT NULL "
"AND subscription_period_id IS NOT NULL)",
name="ck_user_credit_balances_scope_fields",
),
Index(
"ix_user_credit_balances_spendable",
"user_id", "credit_scope", "credit_level_rank", "expires_at", "valid_from", "id",
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_team_spendable",
"team_id", "subscription_id", "subscription_period_id", "credit_level_rank", "expires_at", "id",
postgresql_where=text("credit_scope = 'team' AND unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_expire_due",
"expires_at", "id",
postgresql_where=text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
),
Index("ix_user_credit_balances_source", "source_type", "source_id"),
Index("ix_user_credit_balances_payment", "payment_order_id"),
Index("ix_user_credit_balances_subscription", "subscription_id", "subscription_period_id"),
Index("uq_user_credit_balances_user_biz_key", "user_id", "biz_key", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="资金所有人;团队积分为成交时队长",
)
credit_scope: Mapped[str] = mapped_column(
String(16), nullable=False, default=CreditScope.PERSONAL.value,
server_default=CreditScope.PERSONAL.value,
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
credit_level: Mapped[str] = mapped_column(String(32), nullable=False)
credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
source_type: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
source_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
subscription_period_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="SET NULL"), nullable=True
)
grant_record_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="SET NULL"), nullable=True, index=True
)
grant_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
expired_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
revoked_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value,
server_default=CreditBalanceStatus.ACTIVE.value,
)
expired_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
biz_key: Mapped[str] = mapped_column(String(180), nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+115
View File
@@ -0,0 +1,115 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, CheckConstraint, DateTime, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditLevel
from app.enums.credit_product import CreditProductType
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class CreditProduct(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "credit_products"
__table_args__ = (
Index("uq_credit_products_code", "product_code", unique=True),
Index("ix_credit_products_public", "product_type", "deleted_at", "is_active", "sort_order"),
CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
CheckConstraint(
"first_purchase_price IS NULL OR first_purchase_price >= 0",
name="ck_credit_products_first_price_nonnegative",
),
CheckConstraint(
"regular_price IS NULL OR regular_price >= 0",
name="ck_credit_products_regular_price_nonnegative",
),
CheckConstraint(
"activity_price IS NULL OR activity_price >= 0",
name="ck_credit_products_activity_price_nonnegative",
),
CheckConstraint(
"monthly_grant_credits IS NULL OR monthly_grant_credits > 0",
name="ck_credit_products_monthly_grant_positive",
),
CheckConstraint(
"grant_credits IS NULL OR grant_credits > 0",
name="ck_credit_products_grant_positive",
),
CheckConstraint(
"(product_type IN ('subscription', 'team_subscription') "
"AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
name="ck_credit_products_type_required_fields",
),
CheckConstraint(
"(activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL) "
"OR (activity_price IS NOT NULL AND activity_start_at IS NOT NULL "
"AND activity_end_at IS NOT NULL AND activity_end_at > activity_start_at)",
name="ck_credit_products_activity_window",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
product_code: Mapped[str] = mapped_column(String(64), nullable=False)
product_type: Mapped[str] = mapped_column(String(24), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(96), nullable=False)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 个人/团队订阅套餐字段
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
billing_cycle: Mapped[str | None] = mapped_column(String(24), nullable=True)
monthly_grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
first_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
regular_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 是否允许失去首购资格后的再次购买。不是自动续费开关,不会自动创建订单或扣款。
renewal_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
# 积分增值包字段;有效期按自然月配置,范围1-36个月。
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
validity_months: Mapped[int | None] = mapped_column(nullable=True)
price: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
credit_level: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditLevel.GENERAL.value,
server_default=CreditLevel.GENERAL.value,
)
currency: Mapped[str] = mapped_column(
String(8), nullable=False, default="CNY", server_default="CNY"
)
is_active: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
@property
def is_subscription(self) -> bool:
return self.product_type in {
CreditProductType.SUBSCRIPTION.value,
CreditProductType.TEAM_SUBSCRIPTION.value,
}
@property
def is_team_subscription(self) -> bool:
return self.product_type == CreditProductType.TEAM_SUBSCRIPTION.value
@property
def is_credit_addon(self) -> bool:
return self.product_type == CreditProductType.CREDIT_ADDON.value
@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscription(Base, TimestampMixin):
__tablename__ = "user_credit_subscriptions"
__table_args__ = (
Index("ix_user_credit_subscriptions_user_active", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_team_active", "team_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_expire_due", "status", "expires_at", "id"),
Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_at", "id"),
Index("uq_user_credit_subscriptions_payment", "payment_order_id", unique=True),
Index("uq_user_credit_subscriptions_no", "subscription_no", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
subscription_no: Mapped[str] = mapped_column(
String(32), nullable=False, comment="订阅业务实例编号,供用户/客服/开发定位"
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
comment="成交时的个人用户;团队订阅为成交时队长",
)
team_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
team_manager_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionStatus.PENDING.value,
server_default=CreditSubscriptionStatus.PENDING.value,
)
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
product_type_snapshot: Mapped[str] = mapped_column(String(24), nullable=False)
product_name_snapshot: Mapped[str] = mapped_column(String(96), nullable=False)
tier_code: Mapped[str] = mapped_column(String(32), nullable=False)
tier_rank: Mapped[int] = mapped_column(nullable=False)
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
monthly_grant_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
monthly_total_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
quantity_snapshot: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
grant_count: Mapped[int] = mapped_column(nullable=False)
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
first_purchase_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
regular_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
activity_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False)
paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
@@ -0,0 +1,40 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscriptionPeriod(Base, TimestampMixin):
__tablename__ = "user_credit_subscription_periods"
__table_args__ = (
Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
Index("ix_user_credit_subscription_periods_due", "status", "scheduled_at", "id"),
Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True),
Index("ix_user_credit_subscription_periods_window", "subscription_id", "valid_from", "expires_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="CASCADE"), nullable=False
)
sequence: Mapped[int] = mapped_column(nullable=False)
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
grant_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
allocated_paid_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
server_default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
issued_balance_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
)
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
class TeamSubscriptionSeat(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "team_subscription_seats"
__table_args__ = (
Index("ix_team_subscription_seats_subscription", "subscription_id", "created_at"),
Index("ix_team_subscription_seats_team_id", "team_id"),
CheckConstraint("monthly_allocated_credits > 0", name="ck_team_subscription_seat_allocation_positive"),
Index("ix_team_subscription_seats_user", "user_id", "subscription_id"),
Index(
"uq_team_subscription_seats_active_user",
"subscription_id", "user_id",
unique=True,
postgresql_where=text("deleted_at IS NULL AND cancelled_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
monthly_allocated_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
created_by_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,34 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import CheckConstraint, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamSubscriptionSeatUsage(Base, TimestampMixin):
__tablename__ = "team_subscription_seat_usages"
__table_args__ = (
Index("uq_team_subscription_seat_usage_period", "seat_id", "subscription_period_id", unique=True),
CheckConstraint("used_credits >= 0", name="ck_team_subscription_seat_usage_nonnegative"),
Index("ix_team_subscription_seat_usage_member", "subscription_period_id", "user_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
seat_id: Mapped[str] = mapped_column(
String(32), ForeignKey("team_subscription_seats.id", ondelete="RESTRICT"), nullable=False
)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="RESTRICT"), nullable=False
)
subscription_period_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
used_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
+25 -21
View File
@@ -1,4 +1,9 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -7,14 +12,11 @@ from app.models.base import Base, TimestampMixin
class CreditRecord(Base, TimestampMixin):
__tablename__ = "credit_records"
__table_args__ = (
# 正式计费幂等键:同一用户同一个业务流水只能写入一次。
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
Index(
"uq_credit_records_user_refund_target",
"user_id",
"refund_for_biz_key",
"uq_credit_records_user_refund_target_kind",
"user_id", "refund_for_biz_key", "refund_kind",
unique=True,
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
),
@@ -31,30 +33,30 @@ class CreditRecord(Base, TimestampMixin):
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(16), index=True)
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
description: Mapped[str] = mapped_column(String(256))
type: Mapped[str] = mapped_column(String(24), index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
balance_delta: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
expired_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
balance_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
description: Mapped[str] = mapped_column(String(512))
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
request_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
# 当前积分流水自己的业务幂等键。
# 例如:generation_record:{record_id}:attempt:1:media:charge
biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_for_biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
# 如果当前流水是退款,记录它退的是哪一次扣费。
# 例如:generation_record:{record_id}:attempt:1:media:charge
refund_for_biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
# 账务快照字段:保证业务步骤/资源软删后,流水仍可独立展示。
owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
charge_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
charge_action: Mapped[str | None] = mapped_column(String(16), nullable=True)
charge_action: Mapped[str | None] = mapped_column(String(24), nullable=True)
credit_subject: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
media_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
billing_scene: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
scene_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
credit_level_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_project_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -65,7 +67,10 @@ class CreditRecord(Base, TimestampMixin):
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_success_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_failed_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_billing_execution_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
@@ -75,6 +80,5 @@ class CreditRecord(Base, TimestampMixin):
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
+57
View File
@@ -0,0 +1,57 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Invoice(Base, TimestampMixin):
__tablename__ = "invoices"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
invoice_no: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
header_type: Mapped[str] = mapped_column(String(16), nullable=False)
header_name: Mapped[str] = mapped_column(String(128), nullable=False)
header_tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_register_address: Mapped[str | None] = mapped_column(String(256), nullable=True)
header_register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
header_bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
header_bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True)
email: Mapped[str] = mapped_column(String(128), nullable=False)
total_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
total_credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="processing")
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
issued_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index('idx_invoices_user_created', 'user_id', 'created_at'),
Index('idx_invoices_status_created', 'status', 'created_at'),
)
class InvoiceOrder(Base, TimestampMixin):
__tablename__ = "invoice_orders"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
invoice_id: Mapped[str] = mapped_column(
String(32), ForeignKey("invoices.id", ondelete="CASCADE"), index=True
)
order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False, default=0)
credits: Mapped[float] = mapped_column(Float, nullable=False, default=0)
__table_args__ = (
UniqueConstraint('invoice_id', 'order_id', name='uq_invoice_orders'),
Index('idx_invoice_orders_invoice', 'invoice_id'),
Index('idx_invoice_orders_order', 'order_id'),
)
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class InvoiceHeader(Base):
"""发票抬头表"""
__tablename__ = "invoice_headers"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="主键")
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID"
)
type: Mapped[str] = mapped_column(String(16), nullable=False, comment="抬头类型: personal/company")
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="抬头名称")
tax_no: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="税号")
register_address: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="注册地址")
register_phone: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="注册电话")
bank_name: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="开户行")
bank_account: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="银行账号")
email: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="接收邮箱")
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, comment="是否默认")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="更新时间"
)
__table_args__ = (
Index('idx_invoice_headers_user', 'user_id'),
)
@@ -0,0 +1,5 @@
from app.models.llm_billing.call_attempt import LlmCallAttempt
from app.models.llm_billing.execution import LlmBillingExecution
from app.models.llm_billing.policy import LlmBillingPolicyModel
__all__ = ["LlmCallAttempt", "LlmBillingExecution", "LlmBillingPolicyModel"]
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmCallAttemptStatus
from app.models.base import Base, TimestampMixin
class LlmCallAttempt(Base, TimestampMixin):
__tablename__ = "llm_call_attempts"
__table_args__ = (
Index("uq_llm_call_attempts_sequence", "billing_execution_id", "call_sequence", unique=True),
Index("ix_llm_call_attempts_execution_time", "billing_execution_id", "created_at"),
Index("ix_llm_call_attempts_provider_request", "provider_request_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
billing_execution_id: Mapped[str] = mapped_column(
String(32), ForeignKey("llm_billing_executions.id", ondelete="CASCADE"), nullable=False
)
call_sequence: Mapped[int] = mapped_column(nullable=False)
retry_sequence: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_request_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
request_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
response_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=LlmCallAttemptStatus.STARTED.value,
server_default=LlmCallAttemptStatus.STARTED.value,
)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
token_usage_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("token_usage.id", ondelete="SET NULL"), nullable=True
)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_error_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
token_unavailable_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
postprocess_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
postprocess_error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmBillingExecutionStatus
from app.models.base import Base, TimestampMixin
class LlmBillingExecution(Base, TimestampMixin):
__tablename__ = "llm_billing_executions"
__table_args__ = (
Index(
"uq_llm_billing_executions_business_attempt",
"user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no",
unique=True,
),
Index("uq_llm_billing_executions_credit_record", "credit_record_id", unique=True),
Index("ix_llm_billing_executions_status_time", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
scene_name_snapshot: Mapped[str] = mapped_column(String(128), nullable=False)
owner_type: Mapped[str] = mapped_column(String(64), nullable=False)
owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
business_attempt_no: Mapped[int] = mapped_column(nullable=False)
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
model_parameters_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
billing_policy_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("llm_billing_policies.id", ondelete="SET NULL"), nullable=True
)
billing_policy_version: Mapped[int | None] = mapped_column(nullable=True)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
server_default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
)
total_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
successful_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
failed_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_input_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_output_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
refund_available_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
refund_expired_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
final_error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,22 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import Boolean, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class LlmBillingPolicyModel(Base, TimestampMixin):
__tablename__ = "llm_billing_policies"
__table_args__ = (Index("uq_llm_billing_policies_scene", "scene_code", unique=True),)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False)
scene_name: Mapped[str] = mapped_column(String(128), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+52 -15
View File
@@ -1,34 +1,71 @@
from datetime import datetime
from __future__ import annotations
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.common import PaymentOrderSourceEnum
from app.models.base import Base, TimestampMixin
class PaymentOrder(Base, TimestampMixin):
__tablename__ = "payment_orders"
__table_args__ = (
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
Index("idx_payorder_status_created", "status", "created_at"),
Index("ix_payorder_source_status_created", "order_source", "status", "created_at"),
Index("ix_payorder_team_status_created", "team_id_snapshot", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
amount: Mapped[float] = mapped_column(Float)
credits: Mapped[float] = mapped_column(Float)
payment_method: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, comment="实际整单实收金额")
credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
payment_method: Mapped[str] = mapped_column(String(16))
order_source: Mapped[str] = mapped_column(
String(24), nullable=False, default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
server_default=PaymentOrderSourceEnum.ONLINE_PAYMENT.value,
)
status: Mapped[str] = mapped_column(String(32), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refund_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
refund_entitlement_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
__table_args__ = (
Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_payorder_status_created', 'status', 'created_at'),
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
)
product_type: Mapped[str | None] = mapped_column(String(24), nullable=True, index=True)
purchase_scene: Mapped[str | None] = mapped_column(String(24), nullable=True)
price_type: Mapped[str | None] = mapped_column(String(24), nullable=True)
product_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
product_name_snapshot: Mapped[str | None] = mapped_column(String(96), nullable=True)
product_snapshot_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
quoted_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
quoted_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
actual_unit_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 6), nullable=True)
team_id_snapshot: Mapped[str | None] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=True
)
operator_admin_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
offline_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
offline_payment_detail: Mapped[str | None] = mapped_column(String(128), nullable=True)
remark: Mapped[str | None] = mapped_column(String(512), nullable=True)
subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
fulfillment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+6 -16
View File
@@ -1,19 +1,9 @@
from sqlalchemy import Boolean, Integer, String, Float
from sqlalchemy.orm import Mapped, mapped_column
"""兼容旧导入路径。
from app.models.base import Base, TimestampMixin
充值产品已统一迁移为 credit_products 新代码应直接导入 CreditProduct
"""
from app.models.credit.product import CreditProduct
RechargePackage = CreditProduct
class RechargePackage(Base, TimestampMixin):
__tablename__ = "recharge_packages"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64))
credits: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
bonus_credits: Mapped[float] = mapped_column(Float, default=0.0)
description: Mapped[str | None] = mapped_column(String(256), nullable=True)
package_type: Mapped[str] = mapped_column(String(32), default="normal")
is_gift: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__all__ = ["CreditProduct", "RechargePackage"]
@@ -0,0 +1,23 @@
"""定时任务配置模型。"""
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class ScheduledTask(Base, TimestampMixin):
__tablename__ = "scheduled_tasks"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="任务名称")
task_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="类型: external_api / internal_method")
schedule: Mapped[str] = mapped_column(String(128), nullable=False, comment="Cron 表达式或间隔秒数")
config: Mapped[str | None] = mapped_column(Text, nullable=True, comment="任务配置 JSON")
# external_api config: {url, method, headers, payload}
# internal_method config: {module, function, args}
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="是否启用")
last_run_at: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="最后执行时间 ISO")
last_status: Mapped[str | None] = mapped_column(String(16), nullable=True, comment="最后执行状态: success / error")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="最后执行错误信息")
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="创建者管理员 ID")
+9 -13
View File
@@ -1,4 +1,6 @@
from sqlalchemy import ForeignKey, Index, Integer, String
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.team import TeamStatus
@@ -17,21 +19,15 @@ class Team(Base, TimestampMixin, SoftDeleteMixin):
code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="团队编码")
description: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="团队备注")
status: Mapped[str] = mapped_column(
String(16),
default=TeamStatus.ACTIVE.value,
server_default=TeamStatus.ACTIVE.value,
nullable=False,
index=True,
comment="团队状态:active启用,disabled禁用",
String(16), default=TeamStatus.ACTIVE.value, server_default=TeamStatus.ACTIVE.value,
nullable=False, index=True, comment="团队状态:active启用,disabled禁用",
)
sort_order: Mapped[int] = mapped_column(
Integer,
default=0,
server_default="0",
nullable=False,
index=True,
comment="排序值,越小越靠前",
Integer, default=0, server_default="0", nullable=False, index=True, comment="排序值,越小越靠前",
)
manager_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True, index=True, comment="团队管理人ID"
)
first_subscription_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True, comment="团队首次真实订阅成交时间"
)
@@ -0,0 +1,32 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class TeamManagerHistory(Base, TimestampMixin):
__tablename__ = "team_manager_history"
__table_args__ = (
Index("ix_team_manager_history_team_time", "team_id", "started_at", "ended_at"),
Index("ix_team_manager_history_manager_time", "manager_user_id", "started_at", "ended_at"),
Index(
"uq_team_manager_history_current",
"team_id",
unique=True,
postgresql_where=text("ended_at IS NULL"),
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
team_id: Mapped[str] = mapped_column(
String(32), ForeignKey("teams.id", ondelete="RESTRICT"), nullable=False
)
manager_user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+27 -12
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy import Boolean, DateTime, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind
@@ -9,19 +9,17 @@ from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
__allow_unmapped__ = True
id: Mapped[str] = mapped_column(String(32), primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True)
# 短信注册用户允许先没有密码,后续通过 /auth/set-password 设置。
hashed_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True)
credits: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
user_type: Mapped[str] = mapped_column(String(16), default="frontend", index=True)
# 仅前台用户有业务意义;默认外部用户。取消内部标记时也设置回 external。
frontend_user_kind: Mapped[str] = mapped_column(
String(16),
default=FrontendUserKind.EXTERNAL.value,
@@ -29,21 +27,38 @@ class User(Base, TimestampMixin):
index=True,
nullable=False,
)
# 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
password_set_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
first_membership_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=50, server_default="50", nullable=False
)
# 按设备类型分别管理 Token 版本号 — 单设备登录(同端互斥)
pc_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
mobile_token_version: Mapped[int] = mapped_column(
Integer, default=0, server_default="0", nullable=False
)
# 单设备登录用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
single_device_login_override: Mapped[bool | None] = mapped_column(
Boolean, nullable=True
)
@property
def credits(self) -> float:
return float(getattr(self, "_credits_snapshot", 0.0) or 0.0)
@credits.setter
def credits(self, value: float | int) -> None:
self._credits_snapshot = round(float(value or 0.0), 2)
@property
def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password

Some files were not shown because too many files have changed in this diff Show More