Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e06a739cdd | ||
|
|
fe57416493 | ||
|
|
c47fac821c | ||
|
|
a4417e8976 | ||
|
|
3194356859 | ||
|
|
978475f0cd | ||
|
|
5be40ead52 | ||
|
|
7fc58e62ce | ||
|
|
5eed7686c9 | ||
|
|
4a2355a902 | ||
|
|
7ab5c9a5c9 | ||
|
|
556ea4dbd2 | ||
|
|
049bbb0d24 | ||
|
|
bedf39d4f7 | ||
|
|
abb479c469 | ||
|
|
04fbcc8d8b | ||
|
|
c2971f828f | ||
|
|
45576e7d97 | ||
|
|
1fb75676fc | ||
|
|
fac54b5667 | ||
|
|
1565eeebc5 | ||
|
|
a754a9e004 | ||
|
|
289e458e7c | ||
|
|
3f18b28227 | ||
|
|
7c3ef21762 | ||
|
|
148b89c3ca | ||
|
|
ae92f8b78c | ||
|
|
4db879199c | ||
|
|
59116b93d8 | ||
|
|
07d476e9ac | ||
|
|
5afcfe1513 | ||
|
|
516106c045 | ||
|
|
5e504eddaf | ||
|
|
da3f2cac49 | ||
|
|
a31b58316e | ||
|
|
299dc549cd | ||
|
|
c091960b87 | ||
|
|
ee380cfcc1 | ||
|
|
9b13968762 | ||
|
|
7dcedf974d | ||
|
|
33a5cc4f94 | ||
|
|
b4b629a497 | ||
|
|
2aa737edaa | ||
|
|
fe855e0fa2 | ||
|
|
437be9dac0 | ||
|
|
780769ddee | ||
|
|
b917f19db5 | ||
|
|
4754151cbb | ||
|
|
58e061c9e7 | ||
|
|
6ed82b2e04 | ||
|
|
eb7c60684e | ||
|
|
e3719eae09 | ||
|
|
45b9409fe8 | ||
|
|
81ef619ad3 | ||
|
|
a49ed802f4 | ||
|
|
809ff47120 | ||
|
|
890c1ea83e | ||
|
|
ba32d6f55e | ||
|
|
392b9f4117 | ||
|
|
a1b82635c7 | ||
|
|
41b7cd395b | ||
|
|
b9fd07f293 | ||
|
|
f38f9b09ab | ||
|
|
5bcc7888c2 | ||
|
|
0e47719d5c | ||
|
|
decabb5c42 | ||
|
|
80d0fd26f1 | ||
|
|
487c15779d | ||
|
|
190cf54f0f | ||
|
|
baf97b5fc5 | ||
|
|
7237e8715f | ||
|
|
c1092f0deb | ||
|
|
e83f963534 | ||
|
|
9f7dc731b4 | ||
|
|
2bbcb75328 | ||
|
|
86c65f9ec8 | ||
|
|
d1e8eb7316 | ||
|
|
e364c46ce4 | ||
|
|
a699c9bab8 | ||
|
|
7777454b16 | ||
|
|
e8e590948c | ||
|
|
6d30347cdb | ||
|
|
e77d78db1e | ||
|
|
e2a2b6a16f | ||
|
|
e4fe42991d | ||
|
|
5d2c63ced1 |
Vendored
+156
-146
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
@@ -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: 'MB(1024 × 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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_release(type=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;
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{"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")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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 "已禁用"}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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()]
|
||||
|
||||
@@ -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": "团队已删除"}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
# 情况 3:ISO 字符串(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()))
|
||||
@@ -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] = ["*"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: "其他-线下收款",
|
||||
}
|
||||
|
||||
@@ -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: "积分撤销",
|
||||
}
|
||||
@@ -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: "活动价",
|
||||
}
|
||||
@@ -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: "已到期",
|
||||
}
|
||||
@@ -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分析",
|
||||
}
|
||||
|
||||
@@ -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__"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user