From b9fd07f293001b87185828da2e33e1ddc6ba5a91 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Tue, 11 Aug 2026 09:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=9A=E5=91=98=E7=A7=AF=E5=88=86=E6=94=B9?= =?UTF-8?q?=E7=89=88V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-admin/src/App.tsx | 8 +- video-gen-admin/src/api/index.ts | 85 +- .../src/pages/AdminCreditProducts.tsx | 389 +++++ .../src/pages/AdminCreditRatios.tsx | 62 +- .../src/pages/AdminCreditRecords.tsx | 41 +- .../src/pages/AdminLlmBillingExecutions.tsx | 63 + .../src/pages/AdminLlmBillingPolicies.tsx | 51 + video-gen-admin/src/pages/AdminModels.tsx | 6 +- .../src/pages/AdminRechargePackages.tsx | 254 +-- video-gen-admin/src/pages/AdminSettings.tsx | 105 +- video-gen-admin/src/pages/AdminUsers.tsx | 178 +- video-gen-admin/src/types/index.ts | 116 ++ video-gen-admin/src/utils/formatDate.ts | 20 + ...e41_add_credit_subscription_llm_billing.py | 1037 ++++++++++++ ...9b10_add_credit_product_renewal_enabled.py | 650 +++++++ video-gen-api/app/api/admin/__init__.py | 4 + .../app/api/admin/credit_management.py | 318 ++++ video-gen-api/app/api/admin/llm_billing.py | 89 + .../app/api/admin/recharge_package.py | 144 +- video-gen-api/app/api/v1/__init__.py | 2 + video-gen-api/app/api/v1/admin.py | 51 +- video-gen-api/app/api/v1/auth.py | 72 +- video-gen-api/app/api/v1/credit_products.py | 17 + video-gen-api/app/api/v1/credits.py | 97 +- .../app/api/v1/hot_opening_replicate.py | 13 +- video-gen-api/app/api/v1/payments.py | 101 +- video-gen-api/app/api/v1/recharge_packages.py | 48 +- video-gen-api/app/api/v1/shot_replicate.py | 11 +- video-gen-api/app/api/v1/team.py | 17 +- .../app/cmd/migrate_legacy_llm_holds.py | 66 + video-gen-api/app/cmd/migrate_user_credits.py | 96 ++ .../app/cmd/reconcile_llm_billing.py | 38 + .../app/cmd/reconcile_user_credits.py | 39 + video-gen-api/app/dependencies.py | 3 + video-gen-api/app/enums/celery_queue.py | 2 + video-gen-api/app/enums/credit_balance.py | 46 + video-gen-api/app/enums/credit_product.py | 35 + video-gen-api/app/enums/credit_record.py | 21 +- .../app/enums/credit_subscription.py | 23 + video-gen-api/app/enums/llm_billing.py | 79 +- video-gen-api/app/main.py | 44 +- video-gen-api/app/models/__init__.py | 10 +- video-gen-api/app/models/credit/__init__.py | 13 + video-gen-api/app/models/credit/allocation.py | 47 + video-gen-api/app/models/credit/balance.py | 94 ++ video-gen-api/app/models/credit/product.py | 84 + .../app/models/credit/subscription.py | 54 + .../app/models/credit/subscription_period.py | 45 + video-gen-api/app/models/credit_record.py | 46 +- .../app/models/llm_billing/__init__.py | 5 + .../app/models/llm_billing/call_attempt.py | 50 + .../app/models/llm_billing/execution.py | 71 + .../app/models/llm_billing/policy.py | 22 + video-gen-api/app/models/payment_order.py | 46 +- video-gen-api/app/models/recharge_package.py | 22 +- video-gen-api/app/models/user.py | 26 +- video-gen-api/app/schemas/admin.py | 26 + video-gen-api/app/schemas/credit.py | 24 +- video-gen-api/app/schemas/credit_balance.py | 48 + video-gen-api/app/schemas/credit_product.py | 130 ++ .../app/schemas/credit_subscription.py | 43 + video-gen-api/app/schemas/llm_billing.py | 88 + video-gen-api/app/schemas/payment.py | 20 +- .../services/admin_credit_record_service.py | 43 +- video-gen-api/app/services/credit/__init__.py | 35 + .../app/services/credit/expiration_service.py | 138 ++ .../app/services/credit/ledger_service.py | 791 +++++++++ video-gen-api/app/services/credit/locking.py | 15 + .../app/services/credit/product_service.py | 271 +++ .../app/services/credit/query_service.py | 170 ++ .../services/credit/subscription_service.py | 808 +++++++++ .../app/services/credit/time_policy.py | 39 + .../app/services/credit/upgrade_service.py | 163 ++ video-gen-api/app/services/credit/utils.py | 28 + video-gen-api/app/services/credits.py | 346 ++-- .../services/generation/billing_service.py | 6 +- .../generation/prompt_optimize_service.py | 80 +- .../services/hot_opening_replicate_service.py | 159 +- .../hot_opening_video_prompt_service.py | 50 +- video-gen-api/app/services/llm.py | 145 +- .../app/services/llm_billing/__init__.py | 28 +- .../llm_billing/call_audit_service.py | 271 +++ .../app/services/llm_billing/config.py | 163 +- .../app/services/llm_billing/context.py | 74 +- .../app/services/llm_billing/query_service.py | 54 + .../app/services/llm_billing/service.py | 1501 +++++------------ .../services/module_async_recovery_service.py | 39 +- .../module_generation_v2/flow_service.py | 113 +- video-gen-api/app/services/payment.py | 312 ++-- .../services/shot_replicate_flow_service.py | 158 +- .../shot_replicate_recovery_service.py | 23 +- .../shot_replicate_taskset_service.py | 35 +- .../services/shot_video_analysis_service.py | 92 +- .../app/services/team_manager_service.py | 117 +- video-gen-api/app/tasks/celery_app.py | 8 + video-gen-api/app/tasks/credit_tasks.py | 147 ++ .../app/tasks/shot_replicate_tasks.py | 306 ++-- video-gen-app/src/api/index.ts | 40 +- video-gen-app/src/api/mock.ts | 14 +- .../src/components/Layout/AppLayout.tsx | 514 ++---- video-gen-app/src/pages/CreditRecordsPage.tsx | 42 +- video-gen-app/src/pages/CreditsPage.tsx | 58 +- video-gen-app/src/pages/GeneratePage.tsx | 9 +- video-gen-app/src/pages/InitialInfo.tsx | 10 +- .../src/pages/InitialReplication.tsx | 10 +- video-gen-app/src/pages/RemoveInfo.tsx | 12 +- video-gen-app/src/pages/RemoveRw.tsx | 18 +- .../src/pages/TeamManagementPage.tsx | 126 +- video-gen-app/src/store/useAuthStore.ts | 15 +- video-gen-app/src/types/index.ts | 79 +- video-gen-app/src/utils/formatDate.ts | 25 + 111 files changed, 9355 insertions(+), 3900 deletions(-) create mode 100644 video-gen-admin/src/pages/AdminCreditProducts.tsx create mode 100644 video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx create mode 100644 video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx create mode 100644 video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py create mode 100644 video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py create mode 100644 video-gen-api/app/api/admin/credit_management.py create mode 100644 video-gen-api/app/api/admin/llm_billing.py create mode 100644 video-gen-api/app/api/v1/credit_products.py create mode 100644 video-gen-api/app/cmd/migrate_legacy_llm_holds.py create mode 100644 video-gen-api/app/cmd/migrate_user_credits.py create mode 100644 video-gen-api/app/cmd/reconcile_llm_billing.py create mode 100644 video-gen-api/app/cmd/reconcile_user_credits.py create mode 100644 video-gen-api/app/enums/credit_balance.py create mode 100644 video-gen-api/app/enums/credit_product.py create mode 100644 video-gen-api/app/enums/credit_subscription.py create mode 100644 video-gen-api/app/models/credit/__init__.py create mode 100644 video-gen-api/app/models/credit/allocation.py create mode 100644 video-gen-api/app/models/credit/balance.py create mode 100644 video-gen-api/app/models/credit/product.py create mode 100644 video-gen-api/app/models/credit/subscription.py create mode 100644 video-gen-api/app/models/credit/subscription_period.py create mode 100644 video-gen-api/app/models/llm_billing/__init__.py create mode 100644 video-gen-api/app/models/llm_billing/call_attempt.py create mode 100644 video-gen-api/app/models/llm_billing/execution.py create mode 100644 video-gen-api/app/models/llm_billing/policy.py create mode 100644 video-gen-api/app/schemas/credit_balance.py create mode 100644 video-gen-api/app/schemas/credit_product.py create mode 100644 video-gen-api/app/schemas/credit_subscription.py create mode 100644 video-gen-api/app/schemas/llm_billing.py create mode 100644 video-gen-api/app/services/credit/__init__.py create mode 100644 video-gen-api/app/services/credit/expiration_service.py create mode 100644 video-gen-api/app/services/credit/ledger_service.py create mode 100644 video-gen-api/app/services/credit/locking.py create mode 100644 video-gen-api/app/services/credit/product_service.py create mode 100644 video-gen-api/app/services/credit/query_service.py create mode 100644 video-gen-api/app/services/credit/subscription_service.py create mode 100644 video-gen-api/app/services/credit/time_policy.py create mode 100644 video-gen-api/app/services/credit/upgrade_service.py create mode 100644 video-gen-api/app/services/credit/utils.py create mode 100644 video-gen-api/app/services/llm_billing/call_audit_service.py create mode 100644 video-gen-api/app/services/llm_billing/query_service.py create mode 100644 video-gen-api/app/tasks/credit_tasks.py diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 0d785256..328b675d 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -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'; @@ -94,7 +96,9 @@ const App = () => { } /> } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index eb283323..28b65daf 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -17,7 +17,7 @@ import type { AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, - VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, + VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct, } from '../types'; import type { @@ -1092,3 +1092,86 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise { return api.put(`/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, + }; +} + +export async function getCreditProducts(productType?: 'subscription' | 'credit_addon'): Promise { + const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; + const products = await api.get(`/admin/credit-management/products${query}`); + return products.map(normalizeCreditProduct); +} + +export async function createCreditProduct(payload: Record): Promise { + return normalizeCreditProduct(await api.post('/admin/credit-management/products', payload)); +} + +export async function updateCreditProduct(id: string, payload: Record): Promise { + return normalizeCreditProduct(await api.put(`/admin/credit-management/products/${id}`, payload)); +} + +export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise { + return normalizeCreditProduct(await api.put( + `/admin/credit-management/products/${id}/renewal`, + { renewal_enabled: renewalEnabled }, + )); +} + +export async function disableCreditProduct(id: string): Promise { + await api.delete(`/admin/credit-management/products/${id}`); +} + +export async function getAdminUserCreditSummary(userId: string): Promise { + return api.get(`/admin/credit-management/users/${userId}/summary`); +} + +export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise { + 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 adminGrantCredits(userId: string, payload: Record): Promise { + return api.post(`/admin/credit-management/users/${userId}/grant`, payload); +} + +export async function adminDeductCredits(userId: string, payload: Record): Promise { + return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); +} + +// ── LLM Fixed Pre-deduct ────────────────────────────────── +export async function getLlmBillingPolicies(): Promise { + return api.get('/admin/llm-billing/policies'); +} + +export async function createLlmBillingPolicy(payload: Record): Promise { + return api.post('/admin/llm-billing/policies', payload); +} + +export async function updateLlmBillingPolicy(id: string, payload: Record): Promise { + 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()}`); +} diff --git a/video-gen-admin/src/pages/AdminCreditProducts.tsx b/video-gen-admin/src/pages/AdminCreditProducts.tsx new file mode 100644 index 00000000..47338577 --- /dev/null +++ b/video-gen-admin/src/pages/AdminCreditProducts.tsx @@ -0,0 +1,389 @@ +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 { EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct, +} from '../api'; +import type { CreditProduct, CreditProductType } from '../types'; + +const cycleLabel: Record = { monthly: '月', quarterly: '季', yearly: '年' }; + +const formatMoney = (value?: number | null): string => { + const amount = Number(value); + return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-'; +}; + +const AdminCreditProducts: React.FC = () => { + const [type, setType] = useState('subscription'); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(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, + 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: nextRenewalEnabled, + }); + }; + + const save = async () => { + try { + const values = await form.validateFields(); + const activityRange = values.activityRange || []; + const payload: Record = { + product_code: values.productCode, + product_type: type, + name: values.name, + description: values.description || null, + features: String(values.featuresText || '') + .split('\n') + .map((value) => value.trim()) + .filter(Boolean), + credit_level: 'general', + currency: 'CNY', + is_active: values.isActive ?? true, + sort_order: values.sortOrder ?? 0, + }; + if (type === 'subscription') { + Object.assign(payload, { + tier_code: values.tierCode, + tier_rank: values.tierRank, + billing_cycle: values.billingCycle, + 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, + }); + } + const savedProduct = editing + ? await updateCreditProduct(editing.id, payload) + : await createCreditProduct(payload); + + if (type === 'subscription' && 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 columns = useMemo(() => type === 'subscription' ? [ + { + title: '套餐', + key: 'name', + render: (_: unknown, row: CreditProduct) => <> + {row.name} +
{row.productCode}
+ , + }, + { + title: '等级', + key: 'tier', + render: (_: unknown, row: CreditProduct) => {row.tierCode || '-'} / {row.tierRank ?? '-'}, + }, + { + 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 + ? '-' + : {formatMoney(row.activityPrice)}, + }, + { + title: '允许续费', + dataIndex: 'renewalEnabled', + render: (enabled: boolean, row: CreditProduct) => ( + void toggleRenewal(row, checked)} + /> + ), + }, + { + title: '状态', + dataIndex: 'isActive', + render: (enabled: boolean) => {enabled ? '上架' : '下架'}, + }, + { + title: '操作', + key: 'action', + render: (_: unknown, row: CreditProduct) => + + { + await disableCreditProduct(row.id); + message.success('已下架'); + await load(); + }} + > + + + , + }, + ] : [ + { + title: '增值包', + key: 'name', + render: (_: unknown, row: CreditProduct) => <> + {row.name} +
{row.productCode}
+ , + }, + { title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() }, + { title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) }, + { title: '有效期', render: () => '1 个自然月' }, + { title: '状态', dataIndex: 'isActive', render: (enabled: boolean) => {enabled ? '上架' : '下架'} }, + { + title: '操作', + key: 'action', + render: (_: unknown, row: CreditProduct) => + + { + await disableCreditProduct(row.id); + message.success('已下架'); + await load(); + }} + > + + + , + }, + ], [type, items]); + + return 积分产品} + extra={} + > + setType(key as CreditProductType)} + items={[ + { key: 'subscription', label: '订阅套餐' }, + { key: 'credit_addon', label: '积分增值包' }, + ]} + /> + + { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }} + width={760} + destroyOnClose + > +
+ + + + + + + + + + + {type === 'subscription' ? <> + + + + + + } + + + +
+ ; +}; + +export default AdminCreditProducts; diff --git a/video-gen-admin/src/pages/AdminCreditRatios.tsx b/video-gen-admin/src/pages/AdminCreditRatios.tsx index f593794a..02b5b4df 100644 --- a/video-gen-admin/src/pages/AdminCreditRatios.tsx +++ b/video-gen-admin/src/pages/AdminCreditRatios.tsx @@ -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(); - const [textRate, setTextRate] = useState(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 (
- {/* Text Credit Rate */} - -
- - - 文字积分费率 - - -
- - 文字积分计算公式:ceil(总token数 x 费率 / 1000),最低1积分 - -
- 每1000 token消耗积分: - - setTextRate(v || 0)} - size="large" - style={{ width: '100%' }} - /> - 积分 - - - 示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分 - -
-
- {/* Generation Credit Ratios */}
diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx index 312d65e2..322e9178 100644 --- a/video-gen-admin/src/pages/AdminCreditRecords.tsx +++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx @@ -35,6 +35,8 @@ const RECORD_TYPE_MAP: Record }, consume: { text: '消费', color: 'red', icon: }, refund: { text: '回退', color: 'blue', icon: }, + expire: { text: '过期', color: 'orange', icon: }, + revoke: { text: '撤销', color: 'volcano', icon: }, team_internal: { text: '团队内部', color: 'cyan', icon: }, }; @@ -58,6 +60,8 @@ const recordTypeOptions = [ { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' }, { value: 'refund', label: '回退' }, + { value: 'expire', label: '过期' }, + { value: 'revoke', label: '撤销' }, { value: 'team_internal', label: '团队内部' }, ]; @@ -280,10 +284,13 @@ const AdminCreditRecords: React.FC = () => { { 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: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount }, + { title: '有效余额变动', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceDelta ?? 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 }, @@ -295,6 +302,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: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join(';') }, { title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' }, ]; @@ -345,10 +353,13 @@ const AdminCreditRecords: React.FC = () => { { title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? {r.chargeActionLabel || cfg.text} : 历史; } }, { title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => {v || '-'} }, { title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' }, - { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
}, + { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.sceneNameSnapshot || r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
}, { title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? {v} : '-' }, - { title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)} }, + { title: '业务积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => {n(v)} }, + { title: '有效变动', dataIndex: 'balanceDelta', width: 120, render: (v: number | undefined, r: AdminCreditRecord) => { const value = v ?? r.amount; return 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}; } }, + { title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? {n(v)} : '-' }, { title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) }, + { title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) =>
{n(r.llmCallCount || 0)} 次
成 {n(r.llmSuccessCallCount || 0)} / 败 {n(r.llmFailedCallCount || 0)}
}, { title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) =>
{n(r.totalTokens)}
入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}
}, { title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) =>
{engineTypeLabel(r.engineType)}
{r.engineName || '-'}
{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}
}, { title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => {v ? '已删除' : '正常'} }, @@ -417,7 +428,27 @@ const AdminCreditRecords: React.FC = () => { showSizeChanger: true, showTotal: (t) => `共 ${t} 条记录`, }} - scroll={{ x: 2160 }} + expandable={{ + rowExpandable: (record) => Boolean(record.allocations?.length), + expandedRowRender: (record) => ( +
v || '-' }, + { title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' }, + { title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) }, + { title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' }, + { title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(v) : '-' }, + ]} + scroll={{ x: 1070 }} + /> + ), + }} + scroll={{ x: 2520 }} /> diff --git a/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx new file mode 100644 index 00000000..e715d8ef --- /dev/null +++ b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx @@ -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 = { + pre_deducted: { 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [status, setStatus] = useState(); + 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 LLM调用与积分审计} extra={ setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} />
+ ({ + color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red', + children:
+ {formatDatePrecise(call.requestStartedAt)} 第 {call.callSequence} 次调用:{call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'} +
完成时间:{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}
+
模型:{call.modelNameSnapshot || '-'} 耗时:{call.durationMs == null ? '-' : `${call.durationMs}ms`}
+ {call.tokenUnavailableReason + ?
Token:{call.tokenUnavailableReason}
+ :
输入 Token:{call.inputTokens ?? 0} 输出 Token:{call.outputTokens ?? 0} 总 Token:{call.totalTokens ?? 0}
} + {call.errorMessage && 错误:{call.errorMessage}} + {call.postprocessStatus === 'succeeded' &&
后续处理:成功
} + {call.postprocessError &&
后续处理:失败,{call.postprocessError}
} +
, + }))} /> + +
}} columns={[ + { title: '功能', dataIndex: 'sceneNameSnapshot' }, + { title: '用户', dataIndex: 'userId' }, + { title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` }, + { title: '模型', dataIndex: 'modelNameSnapshot' }, + { title: '预扣', dataIndex: 'preDeductCredits' }, + { 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 {config.label}; } }, + { title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) }, + ]} /> + ; +}; +export default AdminLlmBillingExecutions; diff --git a/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx new file mode 100644 index 00000000..3342cf84 --- /dev/null +++ b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx @@ -0,0 +1,51 @@ +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([]); + const [editing, setEditing] = useState(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, pre_deduct_credits: item.preDeductCredits, is_active: item.isActive } : { is_active: true, pre_deduct_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 LLM固定预扣配置} extra={}> +
{v ? '启用' : '停用'} }, + { title: '操作', render: (_: unknown, r: LlmBillingPolicy) => }, + ]} /> + setOpen(false)}> +
+ = { - normal: 'blue', - gift: 'green', - promo: 'purple', -}; - -const TYPE_LABELS: Record = { - normal: '常规', - gift: '赠送', - promo: '促销', -}; - -const AdminRechargePackages: React.FC = () => { - const [packages, setPackages] = useState([]); - 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) => ( -
- {r.name} - {r.description &&
{r.description}
} -
- ), - }, - { - title: '基础积分', dataIndex: 'credits', width: 100, - render: (v: number) => {v.toLocaleString()}, - }, - { - title: '赠送积分', dataIndex: 'bonusCredits', width: 100, - render: (v: number) => v > 0 - ? +{v.toLocaleString()} - : -, - }, - { - title: '总积分', key: 'total', width: 100, - render: (_: any, r: PackageItem) => ( - - {(r.credits + r.bonusCredits).toLocaleString()} - - ), - }, - { - title: '价格(元)', dataIndex: 'price', width: 100, - render: (v: number) => ¥{v}, - }, - { - title: '类型', dataIndex: 'packageType', width: 80, - render: (v: string) => {TYPE_LABELS[v] || v}, - }, - { - title: '状态', dataIndex: 'isActive', width: 80, - render: (v: boolean) => {v ? '启用' : '停用'}, - }, - { - title: '操作', key: 'action', width: 150, fixed: 'right' as const, - render: (_: any, r: PackageItem) => ( - - - handleDelete(r.id)}> - - - - ), - }, - ]; - - return ( -
- -
- - - 充值套餐管理 - {packages.length} 个套餐 - - -
- -
- - - {modal.item ? '编辑套餐' : '添加套餐'}} - open={modal.open} - onOk={handleSave} - onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }} - okText="保存" cancelText="取消" width={520} - > - - - - -
- - - - - - -
-
- - - - - - -
- - - - - - - - - -
- - -
- ); -}; - -export default AdminRechargePackages; +export { default } from './AdminCreditProducts'; diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx index fee2483a..fc84f816 100644 --- a/video-gen-admin/src/pages/AdminSettings.tsx +++ b/video-gen-admin/src/pages/AdminSettings.tsx @@ -48,14 +48,6 @@ const AdminSettings: React.FC = () => { setConfigs(data); const formValues: Record = {}; 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,80 +62,13 @@ const AdminSettings: React.FC = () => { const handleSave = async () => { try { const values = await form.validateFields(); - const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes( - String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(), - ); - 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'), @@ -254,7 +179,6 @@ const AdminSettings: React.FC = () => { '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 => { @@ -270,13 +194,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 || ''; }; @@ -406,7 +323,7 @@ const AdminSettings: React.FC = () => { ); } - 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 ; } return ; @@ -467,26 +384,6 @@ const AdminSettings: React.FC = () => { {getFieldComponent(config)} ))} - {/* AI创作预扣积分 - 固定显示 */} - 兼容旧预扣积分数量} - extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值" - > - - - {[ - ['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]) => ( - {label}} extra={extra}> - {name === 'llm_billing_enabled' ? : } - - ))} ), diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx index 93be14ca..7b90ca40 100644 --- a/video-gen-admin/src/pages/AdminUsers.tsx +++ b/video-gen-admin/src/pages/AdminUsers.tsx @@ -6,12 +6,15 @@ import { UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, } from '@ant-design/icons'; import { - adjustCredits, + adminDeductCredits, + adminGrantCredits, adminGetPrivatePortraitConfig, adminUpdatePrivatePortraitConfig, createUser, deleteUserResourceCapacity, getAdminUsers, + getAdminUserCreditBalances, + getAdminUserCreditSummary, getMenuConfigs, getTeamOptions, getSystemConfigs, @@ -66,6 +69,12 @@ const AdminUsers: React.FC = () => { const [teamFilter, setTeamFilter] = useState(''); const [teamOptions, setTeamOptions] = useState([]); 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 [creditDetailLoading, setCreditDetailLoading] = useState(false); + const [creditSummary, setCreditSummary] = useState(null); + const [creditBalances, setCreditBalances] = useState([]); + const [creditBalanceStatus, setCreditBalanceStatus] = useState(''); + const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant'); const [createModal, setCreateModal] = useState(false); const [createType, setCreateType] = useState('frontend'); const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); @@ -164,12 +173,44 @@ 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 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) => { @@ -493,10 +534,20 @@ const AdminUsers: React.FC = () => { {!isAdminTab && ( )} + {!isAdminTab && ( + + )} {!isAdminTab && ( - - - - - -
- - `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} + + + + + + + + + )} +
+ 积分明细 - {creditDetailModal.user?.username}} + open={creditDetailModal.open} + footer={null} + width={1180} + onCancel={() => { + setCreditDetailModal({ open: false, user: null }); + setCreditSummary(null); + setCreditBalances([]); + setCreditBalanceStatus(''); + }} + > + +
+ 当前有效积分
{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}
+ 最近即将过期积分
{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}
+ 最近最后可用时间
{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}
+
+ + 积分批次 +
v || '-' }, + { title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' }, + { title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' }, + { 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) => {v} }, + ]} + scroll={{ x: 1410, y: 460 }} + /> + + + 团队设置 - {teamModal.user?.username}} open={teamModal.open} @@ -893,7 +997,7 @@ const AdminUsers: React.FC = () => { {createType === 'frontend' && ( - + )} diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index 00a92680..68f8c452 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -884,6 +884,23 @@ export interface AdminCreditRecordSummary { outputTokens: number; } +export interface AdminCreditRecordAllocation { + id: string; + creditBalanceId: string; + sourceAllocationId?: string | null; + allocationAction: string; + amount: number; + creditLevel?: string; + sourceType?: string; + sourceId?: string; + validFrom?: string; + expiresAt?: string; + unspentBefore?: number; + unspentAfter?: number; + consumedBefore?: number; + consumedAfter?: number; +} + export interface AdminCreditRecord { id: string; userId: string; @@ -901,6 +918,8 @@ export interface AdminCreditRecord { recordTypeLabel?: string; amount: number; balanceAfter: number; + balanceDelta?: number; + expiredAmount?: number; description?: string; relatedId?: string; bizKey?: string; @@ -920,6 +939,12 @@ export interface AdminCreditRecord { mediaTypeLabel?: string; billingScene?: string; billingSceneLabel?: string; + sceneNameSnapshot?: string; + requestTime?: string; + llmCallCount?: number; + llmSuccessCallCount?: number; + llmFailedCallCount?: number; + allocations?: AdminCreditRecordAllocation[]; sourceModule?: string; sourceModuleLabel?: string; sourceProjectId?: string; @@ -1426,3 +1451,94 @@ export interface VideoUpscaleConfigSavePayload { }>; }; } + +// ── Dynamic Credit Products / LLM Billing ───────────────── +export type CreditProductType = 'subscription' | 'credit_addon'; +export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly'; + +export interface CreditProduct { + id: string; + productCode: string; + productType: CreditProductType; + name: string; + description?: string | null; + features?: string[]; + tierCode?: string | null; + tierRank?: number | null; + billingCycle?: SubscriptionBillingCycle | null; + monthlyGrantCredits?: number; + grantCount?: number; + firstPurchasePrice?: number; + regularPrice?: number; + activityPrice?: number | null; + activityStartAt?: string | null; + activityEndAt?: string | null; + renewalEnabled: boolean; + price: number; + grantCredits?: number; + validityMonths?: number | null; + creditLevel: 'promotional' | 'general'; + currency: string; + isActive: boolean; + sortOrder: number; +} + +export interface LlmBillingPolicy { + id: string; + sceneCode: string; + sceneName: string; + preDeductCredits: 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; + preDeductCredits: number; + status: string; + totalCallCount: number; + successfulCallCount: number; + failedCallCount: number; + totalInputTokens: number; + totalOutputTokens: number; + totalTokens: number; + refundAvailableCredits: number; + refundExpiredCredits: number; + finalErrorMessage?: string | null; + calls: LlmCallAttempt[]; +} diff --git a/video-gen-admin/src/utils/formatDate.ts b/video-gen-admin/src/utils/formatDate.ts index 8fba6595..fe78471a 100644 --- a/video-gen-admin/src/utils/formatDate.ts +++ b/video-gen-admin/src/utils/formatDate.ts @@ -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}`; +} diff --git a/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py b/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py new file mode 100644 index 00000000..9fa5a157 --- /dev/null +++ b/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py @@ -0,0 +1,1037 @@ +"""add dynamic credit ledger, subscriptions and llm billing + +Revision ID: 7f3c8a2d9e41 +Revises: 20da1d353914 +Create Date: 2026-08-06 15:43:00+08:00 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "7f3c8a2d9e41" +down_revision: str | None = "20da1d353914" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # 1. 统一积分产品:订阅套餐 / 积分增值包。 + op.create_table( + "credit_products", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("product_code", sa.String(length=64), nullable=False), + sa.Column("product_type", sa.String(length=24), nullable=False), + sa.Column("name", sa.String(length=96), nullable=False), + sa.Column("description", sa.String(length=512), nullable=True), + sa.Column("features_json", sa.JSON(), nullable=True), + sa.Column("tier_code", sa.String(length=32), nullable=True), + sa.Column("tier_rank", sa.Integer(), nullable=True), + sa.Column("billing_cycle", sa.String(length=24), nullable=True), + sa.Column("monthly_grant_credits", sa.Numeric(precision=20, scale=2), nullable=True), + sa.Column("first_purchase_price", sa.Numeric(precision=20, scale=2), nullable=True), + sa.Column("regular_price", sa.Numeric(precision=20, scale=2), nullable=True), + sa.Column("activity_price", sa.Numeric(precision=20, scale=2), nullable=True), + sa.Column("activity_start_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("activity_end_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=True), + sa.Column("validity_months", sa.Integer(), nullable=True), + sa.Column( + "price", + sa.Numeric(precision=20, scale=2), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "credit_level", + sa.String(length=32), + nullable=False, + server_default=sa.text("'general'"), + ), + sa.Column( + "currency", + sa.String(length=8), + nullable=False, + server_default=sa.text("'CNY'"), + ), + sa.Column( + "is_active", + sa.Boolean(), + nullable=False, + server_default=sa.text("true"), + ), + sa.Column( + "sort_order", + sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"), + sa.CheckConstraint( + "first_purchase_price IS NULL OR first_purchase_price >= 0", + name="ck_credit_products_first_price_nonnegative", + ), + sa.CheckConstraint( + "regular_price IS NULL OR regular_price >= 0", + name="ck_credit_products_regular_price_nonnegative", + ), + sa.CheckConstraint( + "activity_price IS NULL OR activity_price >= 0", + name="ck_credit_products_activity_price_nonnegative", + ), + sa.CheckConstraint( + "monthly_grant_credits IS NULL OR monthly_grant_credits > 0", + name="ck_credit_products_monthly_grant_positive", + ), + sa.CheckConstraint( + "grant_credits IS NULL OR grant_credits > 0", + name="ck_credit_products_grant_positive", + ), + sa.CheckConstraint( + "(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 = 1 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", + ), + sa.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", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True) + op.create_index( + "ix_credit_products_public", + "credit_products", + ["product_type", "is_active", "sort_order"], + unique=False, + ) + op.create_index( + "ix_credit_products_product_type", + "credit_products", + ["product_type"], + unique=False, + ) + + # 2. 用户首充状态;删除旧单列余额。 + op.add_column( + "users", + sa.Column("first_membership_paid_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index( + "ix_users_first_membership_paid_at", + "users", + ["first_membership_paid_at"], + unique=False, + ) + + # 3. 扩展支付订单产品、订阅及升级快照。 + op.alter_column( + "payment_orders", + "amount", + existing_type=sa.Float(), + type_=sa.Numeric(precision=20, scale=2), + existing_nullable=False, + postgresql_using="amount::numeric(20,2)", + ) + op.alter_column( + "payment_orders", + "credits", + existing_type=sa.Float(), + type_=sa.Numeric(precision=20, scale=2), + existing_nullable=False, + server_default=sa.text("0"), + postgresql_using="credits::numeric(20,2)", + ) + op.alter_column( + "payment_orders", + "status", + existing_type=sa.String(length=16), + type_=sa.String(length=32), + existing_nullable=False, + ) + op.alter_column( + "payment_orders", + "refund_amount", + existing_type=sa.Float(), + type_=sa.Numeric(precision=20, scale=2), + existing_nullable=True, + postgresql_using="refund_amount::numeric(20,2)", + ) + op.add_column("payment_orders", sa.Column("product_id", sa.String(length=32), nullable=True)) + op.add_column("payment_orders", sa.Column("product_type", sa.String(length=24), nullable=True)) + op.add_column("payment_orders", sa.Column("purchase_scene", sa.String(length=24), nullable=True)) + op.add_column("payment_orders", sa.Column("price_type", sa.String(length=24), nullable=True)) + op.add_column("payment_orders", sa.Column("product_code_snapshot", sa.String(length=64), nullable=True)) + op.add_column("payment_orders", sa.Column("product_name_snapshot", sa.String(length=96), nullable=True)) + op.add_column("payment_orders", sa.Column("product_snapshot_json", sa.JSON(), nullable=True)) + op.add_column("payment_orders", sa.Column("subscription_id", sa.String(length=32), nullable=True)) + op.add_column("payment_orders", sa.Column("source_subscription_id", sa.String(length=32), nullable=True)) + op.add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True)) + op.add_column( + "payment_orders", + sa.Column("target_price_snapshot", sa.Numeric(precision=20, scale=2), nullable=True), + ) + op.add_column( + "payment_orders", + sa.Column("deduction_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True), + ) + op.add_column( + "payment_orders", + sa.Column("payable_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True), + ) + op.add_column("payment_orders", sa.Column("fulfillment_status", sa.String(length=32), nullable=True)) + op.add_column("payment_orders", sa.Column("fulfilled_at", sa.DateTime(timezone=True), nullable=True)) + op.create_foreign_key( + "fk_payment_orders_product_id_credit_products", + "payment_orders", + "credit_products", + ["product_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_payment_orders_product_id", "payment_orders", ["product_id"], unique=False) + op.create_index("ix_payment_orders_product_type", "payment_orders", ["product_type"], unique=False) + op.create_index("ix_payment_orders_subscription_id", "payment_orders", ["subscription_id"], unique=False) + op.create_index( + "ix_payment_orders_source_subscription_id", + "payment_orders", + ["source_subscription_id"], + unique=False, + ) + op.create_index( + "ix_payment_orders_fulfillment_status", + "payment_orders", + ["fulfillment_status"], + unique=False, + ) + + # 4. 用户订阅。 + op.create_table( + "user_credit_subscriptions", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("user_id", sa.String(length=32), nullable=False), + sa.Column("product_id", sa.String(length=32), nullable=True), + sa.Column("payment_order_id", sa.String(length=32), nullable=False), + sa.Column( + "status", + sa.String(length=32), + nullable=False, + server_default=sa.text("'pending'"), + ), + sa.Column("purchase_scene", sa.String(length=24), nullable=False), + sa.Column("tier_code", sa.String(length=32), nullable=False), + sa.Column("tier_rank", sa.Integer(), nullable=False), + sa.Column("billing_cycle", sa.String(length=24), nullable=False), + sa.Column("anchor_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("next_grant_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("monthly_grant_credits_snapshot", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("grant_count", sa.Integer(), nullable=False), + sa.Column("granted_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("paid_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("product_snapshot_json", sa.JSON(), nullable=False), + sa.Column("source_subscription_id", sa.String(length=32), nullable=True), + sa.Column("upgrade_order_id", sa.String(length=32), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint( + ["source_subscription_id"], + ["user_credit_subscriptions.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint(["upgrade_order_id"], ["payment_orders.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_user_credit_subscriptions_current", + "user_credit_subscriptions", + ["user_id", "status", "expires_at"], + unique=False, + ) + op.create_index( + "ix_user_credit_subscriptions_expire_due", + "user_credit_subscriptions", + ["status", "expires_at", "id"], + unique=False, + ) + op.create_index( + "ix_user_credit_subscriptions_grant_due", + "user_credit_subscriptions", + ["status", "next_grant_at", "id"], + unique=False, + ) + op.create_index( + "uq_user_credit_subscriptions_payment", + "user_credit_subscriptions", + ["payment_order_id"], + unique=True, + ) + op.create_index( + "ix_user_credit_subscriptions_user_id", + "user_credit_subscriptions", + ["user_id"], + unique=False, + ) + + # 5. 用户动态积分余额。 + op.create_table( + "user_credit_balances", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("user_id", sa.String(length=32), nullable=False), + sa.Column("credit_level", sa.String(length=32), nullable=False), + sa.Column("credit_level_rank", sa.Integer(), nullable=False, server_default=sa.text("20")), + sa.Column("source_type", sa.String(length=48), nullable=False), + sa.Column("source_id", sa.String(length=64), nullable=True), + sa.Column("product_id", sa.String(length=32), nullable=True), + sa.Column("payment_order_id", sa.String(length=32), nullable=True), + sa.Column("subscription_id", sa.String(length=32), nullable=True), + # 外键在周期表创建后补上,解决双向依赖。 + sa.Column("subscription_period_id", sa.String(length=32), nullable=True), + sa.Column("grant_record_id", sa.String(length=32), nullable=True), + sa.Column("grant_amount", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("unspent_amount", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("consumed_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("expired_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("revoked_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'active'")), + sa.Column("expired_processed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("biz_key", sa.String(length=180), nullable=False), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"), + sa.CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"), + sa.CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"), + sa.CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"), + sa.CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"), + sa.CheckConstraint( + "grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount", + name="ck_user_credit_balances_amount_reconciled", + ), + sa.CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint( + ["subscription_id"], + ["user_credit_subscriptions.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint(["grant_record_id"], ["credit_records.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_user_credit_balances_spendable", + "user_credit_balances", + ["user_id", "credit_level_rank", "expires_at", "valid_from", "id"], + unique=False, + postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"), + ) + op.create_index( + "ix_user_credit_balances_expire_due", + "user_credit_balances", + ["expires_at", "id"], + unique=False, + postgresql_where=sa.text( + "unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL" + ), + ) + op.create_index( + "ix_user_credit_balances_source", + "user_credit_balances", + ["source_type", "source_id"], + unique=False, + ) + op.create_index( + "ix_user_credit_balances_payment", + "user_credit_balances", + ["payment_order_id"], + unique=False, + ) + op.create_index( + "ix_user_credit_balances_subscription", + "user_credit_balances", + ["subscription_id", "subscription_period_id"], + unique=False, + ) + op.create_index( + "uq_user_credit_balances_user_biz_key", + "user_credit_balances", + ["user_id", "biz_key"], + unique=True, + ) + op.create_index("ix_user_credit_balances_user_id", "user_credit_balances", ["user_id"], unique=False) + op.create_index("ix_user_credit_balances_source_type", "user_credit_balances", ["source_type"], unique=False) + op.create_index("ix_user_credit_balances_source_id", "user_credit_balances", ["source_id"], unique=False) + op.create_index("ix_user_credit_balances_grant_record_id", "user_credit_balances", ["grant_record_id"], unique=False) + op.create_index("ix_user_credit_balances_valid_from", "user_credit_balances", ["valid_from"], unique=False) + op.create_index("ix_user_credit_balances_expires_at", "user_credit_balances", ["expires_at"], unique=False) + + # 6. 订阅月度发放周期。 + op.create_table( + "user_credit_subscription_periods", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("subscription_id", sa.String(length=32), nullable=False), + sa.Column("sequence", sa.Integer(), nullable=False), + sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("allocated_paid_amount", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'scheduled'")), + sa.Column("issued_balance_id", sa.String(length=32), nullable=True), + sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("upgrade_order_id", sa.String(length=32), nullable=True), + sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["subscription_id"], + ["user_credit_subscriptions.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["issued_balance_id"], + ["user_credit_balances.id"], + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["upgrade_order_id"], + ["payment_orders.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_user_credit_subscription_periods_sequence", + "user_credit_subscription_periods", + ["subscription_id", "sequence"], + unique=True, + ) + op.create_index( + "ix_user_credit_subscription_periods_due", + "user_credit_subscription_periods", + ["status", "scheduled_at", "id"], + unique=False, + ) + op.create_index( + "ix_user_credit_subscription_periods_upgrade", + "user_credit_subscription_periods", + ["upgrade_order_id", "status"], + unique=False, + ) + op.create_index( + "uq_user_credit_subscription_periods_balance", + "user_credit_subscription_periods", + ["issued_balance_id"], + unique=True, + ) + op.create_foreign_key( + "fk_user_credit_balances_subscription_period_id", + "user_credit_balances", + "user_credit_subscription_periods", + ["subscription_period_id"], + ["id"], + ondelete="SET NULL", + ) + + # 7. 积分消费/退款来源分摊。 + op.create_table( + "credit_record_allocations", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("credit_record_id", sa.String(length=32), nullable=False), + sa.Column("credit_balance_id", sa.String(length=32), nullable=False), + sa.Column("user_id", sa.String(length=32), nullable=False), + sa.Column("source_allocation_id", sa.String(length=32), nullable=True), + sa.Column("allocation_action", sa.String(length=48), nullable=False), + sa.Column("amount", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("request_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("credit_level_snapshot", sa.String(length=32), nullable=False), + sa.Column("source_type_snapshot", sa.String(length=48), nullable=False), + sa.Column("source_id_snapshot", sa.String(length=64), nullable=True), + sa.Column("valid_from_snapshot", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at_snapshot", sa.DateTime(timezone=True), nullable=False), + sa.Column("unspent_before", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("unspent_after", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("consumed_before", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("consumed_after", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["credit_balance_id"], + ["user_credit_balances.id"], + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["source_allocation_id"], + ["credit_record_allocations.id"], + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_credit_record_allocations_record", + "credit_record_allocations", + ["credit_record_id", "id"], + unique=False, + ) + op.create_index( + "ix_credit_record_allocations_balance", + "credit_record_allocations", + ["credit_balance_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_credit_record_allocations_user_time", + "credit_record_allocations", + ["user_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_credit_record_allocations_source_allocation", + "credit_record_allocations", + ["source_allocation_id"], + unique=False, + ) + op.create_index( + "ix_credit_record_allocations_allocation_action", + "credit_record_allocations", + ["allocation_action"], + unique=False, + ) + + # 8. 扩展积分流水。 + op.drop_index("uq_credit_records_user_refund_target", table_name="credit_records") + op.alter_column( + "credit_records", + "type", + existing_type=sa.String(length=16), + type_=sa.String(length=24), + existing_nullable=False, + ) + op.alter_column( + "credit_records", + "amount", + existing_type=sa.Float(), + type_=sa.Numeric(precision=20, scale=2), + existing_nullable=False, + postgresql_using="amount::numeric(20,2)", + ) + op.alter_column( + "credit_records", + "balance_after", + existing_type=sa.Float(), + type_=sa.Numeric(precision=20, scale=2), + existing_nullable=False, + postgresql_using="balance_after::numeric(20,2)", + ) + op.alter_column( + "credit_records", + "description", + existing_type=sa.String(length=256), + type_=sa.String(length=512), + existing_nullable=False, + ) + op.alter_column( + "credit_records", + "biz_key", + existing_type=sa.String(length=160), + type_=sa.String(length=180), + existing_nullable=True, + ) + op.alter_column( + "credit_records", + "refund_for_biz_key", + existing_type=sa.String(length=160), + type_=sa.String(length=180), + existing_nullable=True, + ) + op.alter_column( + "credit_records", + "charge_action", + existing_type=sa.String(length=16), + type_=sa.String(length=24), + existing_nullable=True, + ) + op.add_column( + "credit_records", + sa.Column( + "balance_delta", + sa.Numeric(precision=20, scale=2), + nullable=False, + server_default=sa.text("0"), + ), + ) + op.add_column( + "credit_records", + sa.Column( + "expired_amount", + sa.Numeric(precision=20, scale=2), + nullable=False, + server_default=sa.text("0"), + ), + ) + op.add_column("credit_records", sa.Column("request_time", sa.DateTime(timezone=True), nullable=True)) + op.add_column("credit_records", sa.Column("refund_kind", sa.String(length=32), nullable=True)) + op.add_column("credit_records", sa.Column("scene_name_snapshot", sa.String(length=128), nullable=True)) + op.add_column("credit_records", sa.Column("credit_level_snapshot", sa.String(length=32), nullable=True)) + op.add_column("credit_records", sa.Column("llm_call_count", sa.Integer(), nullable=True)) + op.add_column("credit_records", sa.Column("llm_success_call_count", sa.Integer(), nullable=True)) + op.add_column("credit_records", sa.Column("llm_failed_call_count", sa.Integer(), nullable=True)) + op.add_column("credit_records", sa.Column("llm_billing_execution_id", sa.String(length=32), nullable=True)) + op.create_index("ix_credit_records_request_time", "credit_records", ["request_time"], unique=False) + op.create_index("ix_credit_records_refund_kind", "credit_records", ["refund_kind"], unique=False) + op.create_index( + "ix_credit_records_llm_billing_execution_id", + "credit_records", + ["llm_billing_execution_id"], + unique=False, + ) + op.create_index( + "uq_credit_records_user_refund_target_kind", + "credit_records", + ["user_id", "refund_for_biz_key", "refund_kind"], + unique=True, + postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"), + ) + + # 9. LLM 场景固定预扣及逐次调用审计。 + op.create_table( + "llm_billing_policies", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("scene_code", sa.String(length=64), nullable=False), + sa.Column("scene_name", sa.String(length=128), nullable=False), + sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("1")), + sa.Column("created_by", sa.String(length=32), nullable=True), + sa.Column("updated_by", sa.String(length=32), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_llm_billing_policies_scene", + "llm_billing_policies", + ["scene_code"], + unique=True, + ) + + op.create_table( + "llm_billing_executions", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("user_id", sa.String(length=32), nullable=False), + sa.Column("scene_code", sa.String(length=64), nullable=False), + sa.Column("scene_name_snapshot", sa.String(length=128), nullable=False), + sa.Column("owner_type", sa.String(length=64), nullable=False), + sa.Column("owner_id", sa.String(length=64), nullable=False), + sa.Column("business_attempt_no", sa.Integer(), nullable=False), + sa.Column("model_config_id", sa.String(length=32), nullable=True), + sa.Column("model_name_snapshot", sa.String(length=128), nullable=True), + sa.Column("provider_snapshot", sa.String(length=64), nullable=True), + sa.Column("model_parameters_snapshot", sa.JSON(), nullable=True), + sa.Column("billing_policy_id", sa.String(length=32), nullable=True), + sa.Column("billing_policy_version", sa.Integer(), nullable=True), + sa.Column("request_time", sa.DateTime(timezone=True), nullable=False), + sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False), + sa.Column("credit_record_id", sa.String(length=32), nullable=False), + sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'pre_deducted'")), + sa.Column("total_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("successful_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("failed_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("total_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("refund_available_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("refund_expired_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")), + sa.Column("final_error_message", sa.String(length=1000), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("refunded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["billing_policy_id"], ["llm_billing_policies.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_llm_billing_executions_business_attempt", + "llm_billing_executions", + ["user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no"], + unique=True, + ) + op.create_index( + "uq_llm_billing_executions_credit_record", + "llm_billing_executions", + ["credit_record_id"], + unique=True, + ) + op.create_index( + "ix_llm_billing_executions_status_time", + "llm_billing_executions", + ["status", "created_at"], + unique=False, + ) + op.create_index("ix_llm_billing_executions_user_id", "llm_billing_executions", ["user_id"], unique=False) + op.create_index("ix_llm_billing_executions_scene_code", "llm_billing_executions", ["scene_code"], unique=False) + + op.create_table( + "llm_call_attempts", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("billing_execution_id", sa.String(length=32), nullable=False), + sa.Column("call_sequence", sa.Integer(), nullable=False), + sa.Column("retry_sequence", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("model_config_id", sa.String(length=32), nullable=True), + sa.Column("model_name_snapshot", sa.String(length=128), nullable=True), + sa.Column("provider_snapshot", sa.String(length=64), nullable=True), + sa.Column("provider_request_id", sa.String(length=160), nullable=True), + sa.Column("request_started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("response_received_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'started'")), + sa.Column("input_tokens", sa.Integer(), nullable=True), + sa.Column("output_tokens", sa.Integer(), nullable=True), + sa.Column("total_tokens", sa.Integer(), nullable=True), + sa.Column("token_usage_id", sa.String(length=32), nullable=True), + sa.Column("http_status", sa.Integer(), nullable=True), + sa.Column("provider_error_code", sa.String(length=128), nullable=True), + sa.Column("error_message", sa.String(length=1000), nullable=True), + sa.Column("token_unavailable_reason", sa.String(length=256), nullable=True), + sa.Column("postprocess_status", sa.String(length=32), nullable=True), + sa.Column("postprocess_error", sa.String(length=1000), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.ForeignKeyConstraint( + ["billing_execution_id"], + ["llm_billing_executions.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["token_usage_id"], ["token_usage.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "uq_llm_call_attempts_sequence", + "llm_call_attempts", + ["billing_execution_id", "call_sequence"], + unique=True, + ) + op.create_index( + "ix_llm_call_attempts_execution_time", + "llm_call_attempts", + ["billing_execution_id", "created_at"], + unique=False, + ) + op.create_index( + "ix_llm_call_attempts_provider_request", + "llm_call_attempts", + ["provider_request_id"], + unique=False, + ) + + + +def downgrade() -> None: + # 恢复 users.credits 与旧充值包表。 + + op.drop_index("ix_llm_call_attempts_provider_request", table_name="llm_call_attempts") + op.drop_index("ix_llm_call_attempts_execution_time", table_name="llm_call_attempts") + op.drop_index("uq_llm_call_attempts_sequence", table_name="llm_call_attempts") + op.drop_table("llm_call_attempts") + + op.drop_index("ix_llm_billing_executions_scene_code", table_name="llm_billing_executions") + op.drop_index("ix_llm_billing_executions_user_id", table_name="llm_billing_executions") + op.drop_index("ix_llm_billing_executions_status_time", table_name="llm_billing_executions") + op.drop_index("uq_llm_billing_executions_credit_record", table_name="llm_billing_executions") + op.drop_index("uq_llm_billing_executions_business_attempt", table_name="llm_billing_executions") + op.drop_table("llm_billing_executions") + + op.drop_index("uq_llm_billing_policies_scene", table_name="llm_billing_policies") + op.drop_table("llm_billing_policies") + + op.drop_index("uq_credit_records_user_refund_target_kind", table_name="credit_records") + op.drop_index("ix_credit_records_llm_billing_execution_id", table_name="credit_records") + op.drop_index("ix_credit_records_refund_kind", table_name="credit_records") + op.drop_index("ix_credit_records_request_time", table_name="credit_records") + op.drop_column("credit_records", "llm_billing_execution_id") + op.drop_column("credit_records", "llm_failed_call_count") + op.drop_column("credit_records", "llm_success_call_count") + op.drop_column("credit_records", "llm_call_count") + op.drop_column("credit_records", "credit_level_snapshot") + op.drop_column("credit_records", "scene_name_snapshot") + op.drop_column("credit_records", "refund_kind") + op.drop_column("credit_records", "request_time") + op.drop_column("credit_records", "expired_amount") + op.drop_column("credit_records", "balance_delta") + op.alter_column( + "credit_records", + "charge_action", + existing_type=sa.String(length=24), + type_=sa.String(length=16), + existing_nullable=True, + ) + op.alter_column( + "credit_records", + "refund_for_biz_key", + existing_type=sa.String(length=180), + type_=sa.String(length=160), + existing_nullable=True, + ) + op.alter_column( + "credit_records", + "biz_key", + existing_type=sa.String(length=180), + type_=sa.String(length=160), + existing_nullable=True, + ) + op.alter_column( + "credit_records", + "description", + existing_type=sa.String(length=512), + type_=sa.String(length=256), + existing_nullable=False, + ) + op.alter_column( + "credit_records", + "balance_after", + existing_type=sa.Numeric(precision=20, scale=2), + type_=sa.Float(), + existing_nullable=False, + postgresql_using="balance_after::double precision", + ) + op.alter_column( + "credit_records", + "amount", + existing_type=sa.Numeric(precision=20, scale=2), + type_=sa.Float(), + existing_nullable=False, + postgresql_using="amount::double precision", + ) + op.alter_column( + "credit_records", + "type", + existing_type=sa.String(length=24), + type_=sa.String(length=16), + existing_nullable=False, + ) + op.create_index( + "uq_credit_records_user_refund_target", + "credit_records", + ["user_id", "refund_for_biz_key"], + unique=True, + postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"), + ) + + op.drop_index("ix_credit_record_allocations_allocation_action", table_name="credit_record_allocations") + op.drop_index("ix_credit_record_allocations_source_allocation", table_name="credit_record_allocations") + op.drop_index("ix_credit_record_allocations_user_time", table_name="credit_record_allocations") + op.drop_index("ix_credit_record_allocations_balance", table_name="credit_record_allocations") + op.drop_index("ix_credit_record_allocations_record", table_name="credit_record_allocations") + op.drop_table("credit_record_allocations") + + op.drop_constraint( + "fk_user_credit_balances_subscription_period_id", + "user_credit_balances", + type_="foreignkey", + ) + op.drop_index("uq_user_credit_subscription_periods_balance", table_name="user_credit_subscription_periods") + op.drop_index("ix_user_credit_subscription_periods_upgrade", table_name="user_credit_subscription_periods") + op.drop_index("ix_user_credit_subscription_periods_due", table_name="user_credit_subscription_periods") + op.drop_index("uq_user_credit_subscription_periods_sequence", table_name="user_credit_subscription_periods") + op.drop_table("user_credit_subscription_periods") + + op.drop_index("ix_user_credit_balances_expires_at", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_valid_from", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_grant_record_id", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_source_id", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_source_type", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_user_id", table_name="user_credit_balances") + op.drop_index("uq_user_credit_balances_user_biz_key", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_subscription", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_payment", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_source", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_expire_due", table_name="user_credit_balances") + op.drop_index("ix_user_credit_balances_spendable", table_name="user_credit_balances") + op.drop_table("user_credit_balances") + + op.drop_index("ix_user_credit_subscriptions_user_id", table_name="user_credit_subscriptions") + op.drop_index("uq_user_credit_subscriptions_payment", table_name="user_credit_subscriptions") + op.drop_index("ix_user_credit_subscriptions_grant_due", table_name="user_credit_subscriptions") + op.drop_index("ix_user_credit_subscriptions_expire_due", table_name="user_credit_subscriptions") + op.drop_index("ix_user_credit_subscriptions_current", table_name="user_credit_subscriptions") + op.drop_table("user_credit_subscriptions") + + op.drop_index("ix_payment_orders_fulfillment_status", table_name="payment_orders") + op.drop_index("ix_payment_orders_source_subscription_id", table_name="payment_orders") + op.drop_index("ix_payment_orders_subscription_id", table_name="payment_orders") + op.drop_index("ix_payment_orders_product_type", table_name="payment_orders") + op.drop_index("ix_payment_orders_product_id", table_name="payment_orders") + op.drop_constraint( + "fk_payment_orders_product_id_credit_products", + "payment_orders", + type_="foreignkey", + ) + op.drop_column("payment_orders", "fulfilled_at") + op.drop_column("payment_orders", "fulfillment_status") + op.drop_column("payment_orders", "payable_amount_snapshot") + op.drop_column("payment_orders", "deduction_amount_snapshot") + op.drop_column("payment_orders", "target_price_snapshot") + op.drop_column("payment_orders", "upgrade_period_ids_json") + op.drop_column("payment_orders", "source_subscription_id") + op.drop_column("payment_orders", "subscription_id") + op.drop_column("payment_orders", "product_snapshot_json") + op.drop_column("payment_orders", "product_name_snapshot") + op.drop_column("payment_orders", "product_code_snapshot") + op.drop_column("payment_orders", "price_type") + op.drop_column("payment_orders", "purchase_scene") + op.drop_column("payment_orders", "product_type") + op.drop_column("payment_orders", "product_id") + op.alter_column( + "payment_orders", + "refund_amount", + existing_type=sa.Numeric(precision=20, scale=2), + type_=sa.Float(), + existing_nullable=True, + postgresql_using="refund_amount::double precision", + ) + op.alter_column( + "payment_orders", + "status", + existing_type=sa.String(length=32), + type_=sa.String(length=16), + existing_nullable=False, + ) + op.alter_column( + "payment_orders", + "credits", + existing_type=sa.Numeric(precision=20, scale=2), + type_=sa.Float(), + existing_nullable=False, + server_default=None, + postgresql_using="credits::double precision", + ) + op.alter_column( + "payment_orders", + "amount", + existing_type=sa.Numeric(precision=20, scale=2), + type_=sa.Float(), + existing_nullable=False, + postgresql_using="amount::double precision", + ) + + op.drop_index("ix_users_first_membership_paid_at", table_name="users") + op.drop_column("users", "first_membership_paid_at") + + op.drop_index("ix_credit_products_product_type", table_name="credit_products") + op.drop_index("ix_credit_products_public", table_name="credit_products") + op.drop_index("uq_credit_products_code", table_name="credit_products") + op.drop_table("credit_products") diff --git a/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py b/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py new file mode 100644 index 00000000..680714f2 --- /dev/null +++ b/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py @@ -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") diff --git a/video-gen-api/app/api/admin/__init__.py b/video-gen-api/app/api/admin/__init__.py index 9db3c0bc..193694df 100644 --- a/video-gen-api/app/api/admin/__init__.py +++ b/video-gen-api/app/api/admin/__init__.py @@ -7,6 +7,8 @@ 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 @@ -19,6 +21,8 @@ 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) diff --git a/video-gen-api/app/api/admin/credit_management.py b/video-gen-api/app/api/admin/credit_management.py new file mode 100644 index 00000000..8d8df660 --- /dev/null +++ b/video-gen-api/app/api/admin/credit_management.py @@ -0,0 +1,318 @@ +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 CreditBalanceSourceType +from app.models.credit.balance import UserCreditBalance +from app.models.credit.product import CreditProduct +from app.models.user import User +from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest +from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductUpdate +from app.services.credit.ledger_service import deduct_credits, grant_credits +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 == "credit_addon": + product.validity_months = 1 + if product.product_type == "subscription": + product.price = product.regular_price or 0 + product.grant_credits = None + product.validity_months = None + else: + product.renewal_enabled = False + 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 == "subscription": + 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 == "credit_addon": + if product.grant_credits is None or product.price is None: + raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量") + 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.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, detail=snapshot) + # 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖 + # yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。 + 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="商品不存在") + before = product_to_dict(product) + payload = data.model_dump(exclude_unset=True) + new_code = payload.get("product_code") + if new_code and new_code != product.product_code: + duplicate = await db.execute( + select(CreditProduct.id).where( + CreditProduct.product_code == new_code, CreditProduct.id != product.id + ).limit(1) + ) + if duplicate.scalar_one_or_none(): + raise HTTPException(status_code=409, detail="商品编码已存在") + _apply_product_payload(product, payload) + _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)) + log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id}) + await db.commit() + refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1)) + persisted = refreshed.scalar_one_or_none() + if persisted is None: + raise HTTPException(status_code=404, detail="商品不存在") + return product_to_dict(persisted) + + +@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.product_type != "subscription": + raise HTTPException(status_code=400, detail="积分增值包不支持续费开关") + + before = bool(product.renewal_enabled) + product.renewal_enabled = bool(data.renewal_enabled) + await db.flush() + 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, + ), + ) + await db.commit() + + # 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。 + refreshed = await db.execute( + select(CreditProduct).where(CreditProduct.id == product_id).limit(1) + ) + persisted = refreshed.scalar_one_or_none() + if persisted is None: + raise HTTPException(status_code=404, detail="商品不存在") + if bool(persisted.renewal_enabled) != bool(data.renewal_enabled): + raise HTTPException(status_code=500, detail="续费状态保存后校验失败") + return product_to_dict(persisted) + + +@router.delete("/products/{product_id}") +async def disable_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="商品不存在") + product.is_active = False + 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} + + +@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_level": item.credit_level, + "source_type": 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": effective_balance_status(item, request_time=checked_at), + } + 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()}", + ) + 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} diff --git a/video-gen-api/app/api/admin/llm_billing.py b/video-gen-api/app/api/admin/llm_billing.py new file mode 100644 index 00000000..2d8c81a1 --- /dev/null +++ b/video-gen-api/app/api/admin/llm_billing.py @@ -0,0 +1,89 @@ +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"]) + + +@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 list(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.pre_deduct_credits, + is_active=data.is_active, + version=1, + created_by=admin.id, + updated_by=admin.id, + ) + db.add(policy) + await db.flush() + log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_CREATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "credits": float(policy.pre_deduct_credits)}) + return 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) + for key, value in payload.items(): + setattr(policy, key, value) + 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, "version": policy.version}) + return 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} diff --git a/video-gen-api/app/api/admin/recharge_package.py b/video-gen-api/app/api/admin/recharge_package.py index bf23147e..458fbb61 100644 --- a/video-gen-api/app/api/admin/recharge_package.py +++ b/video-gen-api/app/api/admin/recharge_package.py @@ -1,148 +1,24 @@ -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) + .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()] diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py index bd89d179..e93159c3 100644 --- a/video-gen-api/app/api/v1/__init__.py +++ b/video-gen-api/app/api/v1/__init__.py @@ -12,6 +12,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 @@ -49,6 +50,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) diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index c2f37871..a6a16e93 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -48,10 +48,13 @@ 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_available_credits, get_user_credit_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 @@ -130,6 +133,9 @@ 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_map = await get_user_credit_map(db, user_ids) + for item in users: + attach_credit_snapshot(item, credit_map.get(item.id, 0.0)) return { "items": [ AdminUserOut.model_validate(user) @@ -178,16 +184,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,7 +276,7 @@ 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) + attach_credit_snapshot(user, await get_available_credits(db, user.id)) 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( @@ -276,7 +295,17 @@ 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 create_notification( @@ -1526,6 +1555,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 +1585,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 +1662,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 +1696,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( diff --git a/video-gen-api/app/api/v1/auth.py b/video-gen-api/app/api/v1/auth.py index b68a7ea3..6397329c 100644 --- a/video-gen-api/app/api/v1/auth.py +++ b/video-gen-api/app/api/v1/auth.py @@ -12,7 +12,6 @@ from app.dependencies import ( get_current_user_allow_password_pending, get_db, ) -from app.models.credit_record import CreditRecord from app.models.system_config import SystemConfig from app.models.user import User from app.schemas.auth import ( @@ -34,6 +33,10 @@ from app.services.auth import ( ) 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"]) @@ -93,50 +96,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( @@ -226,7 +236,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)): phone=req.phone, hashed_password=hash_password(req.password), password_set_at=datetime.now(CST), - credits=register_credits, is_admin=False, user_type="frontend", ) @@ -342,7 +351,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)): """Public endpoint returning site name, logo, agreement and copyright info.""" result = await db.execute( select(SystemConfig).where(SystemConfig.key.in_([ - "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits" + "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video" ])) ) configs = result.scalars().all() @@ -366,7 +375,6 @@ async def get_site_info(db: AsyncSession = Depends(get_db)): "site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"), "operation_manual": info.get("operation_manual", ""), "login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "", - "optimize_hold_credits": int(info.get("optimize_hold_credits") or 5), } diff --git a/video-gen-api/app/api/v1/credit_products.py b/video-gen-api/app/api/v1/credit_products.py new file mode 100644 index 00000000..04a39723 --- /dev/null +++ b/video-gen-api/app/api/v1/credit_products.py @@ -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) diff --git a/video-gen-api/app/api/v1/credits.py b/video-gen-api/app/api/v1/credits.py index ab893508..fee34b36 100644 --- a/video-gen-api/app/api/v1/credits.py +++ b/video-gen-api/app/api/v1/credits.py @@ -1,20 +1,53 @@ 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_record import CreditRecord 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_level=item.credit_level, + source_type=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), + created_at=item.created_at, + ) + + @router.get("") async def get_credits( page: int = Query(1, ge=1), @@ -23,18 +56,47 @@ 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), + **summary.to_dict(), "records": [CreditRecordOut.model_validate(r) for r in 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() + stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id) + 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 +121,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 diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py index 3369ea51..57c0dced 100644 --- a/video-gen-api/app/api/v1/hot_opening_replicate.py +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -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 "爆款开头复刻视频提词优化" diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 60c12a77..c867bdbc 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -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.upgrade_service import release_upgrade_reservation +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, ) router = APIRouter(prefix="/payments", tags=["payments"]) @@ -57,27 +56,23 @@ 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), + ).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, ) - 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 +81,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 +99,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 +132,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 +148,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 +176,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 +200,53 @@ 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}" ) - + # 处理退款回调 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", "") - + 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)) order = result.scalar_one_or_none() - + if order and order.status == "refunding": order.status = "refunded" order.transaction_id = refund_id await db.commit() - + logger.info( f"WeChat refund callback processed: order_no={order_no}, " f"refund_id={refund_id}, status={refund_status}" ) - + return {"code": "SUCCESS", "message": "OK"} except Exception as e: logger.exception(f"WeChat callback processing error: {e}") @@ -263,7 +258,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 +278,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) @@ -350,14 +345,14 @@ 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 db_configs = await _get_payment_configs(db) if order.payment_method == "alipay": @@ -371,8 +366,10 @@ async def cancel_order( await _close_wechat_order(db, order, db_configs) except Exception as e: logger.exception(f"Failed to close WeChat order {order_no}: {e}") - + order.status = "cancelled" + if order.upgrade_period_ids_json: + await release_upgrade_reservation(db, order=order, released_at=utc_now()) await db.flush() logger.info( f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}" diff --git a/video-gen-api/app/api/v1/recharge_packages.py b/video-gen-api/app/api/v1/recharge_packages.py index a6d95b14..9e1e31c8 100644 --- a/video-gen-api/app/api/v1/recharge_packages.py +++ b/video-gen-api/app/api/v1/recharge_packages.py @@ -1,39 +1,41 @@ +"""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), + ) + .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 diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index 2ca75228..bb41bbbd 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -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}", diff --git a/video-gen-api/app/api/v1/team.py b/video-gen-api/app/api/v1/team.py index 88cde7c2..abbf8800 100644 --- a/video-gen-api/app/api/v1/team.py +++ b/video-gen-api/app/api/v1/team.py @@ -9,10 +9,8 @@ from sqlalchemy import 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.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 @@ -23,17 +21,12 @@ from app.schemas.team_join_request import ( JoinTeamInfoOut, ) from app.schemas.team_manager import ( - ManagedTeamOut, ManagerTransferRequest, - SetManagerRequest, - TeamMemberOut, ) from app.services import team_invitation_service from app.services.team_manager_service import ( get_managed_team, get_team_members, - is_team_manager, - transfer_credits_to_member, ) router = APIRouter(prefix="/team", tags=["team"]) @@ -92,15 +85,7 @@ async def transfer_credits( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - await transfer_credits_to_member( - db, - current_user.id, - req.target_user_id, - req.amount, - req.direction or "increase", - req.description, - ) - return {"message": "ok"} + raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能") # ── 邀请码管理 ──────────────────────────────────────── diff --git a/video-gen-api/app/cmd/migrate_legacy_llm_holds.py b/video-gen-api/app/cmd/migrate_legacy_llm_holds.py new file mode 100644 index 00000000..3a34b7cc --- /dev/null +++ b/video-gen-api/app/cmd/migrate_legacy_llm_holds.py @@ -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())) diff --git a/video-gen-api/app/cmd/migrate_user_credits.py b/video-gen-api/app/cmd/migrate_user_credits.py new file mode 100644 index 00000000..a436a78a --- /dev/null +++ b/video-gen-api/app/cmd/migrate_user_credits.py @@ -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, 1), + 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()) diff --git a/video-gen-api/app/cmd/reconcile_llm_billing.py b/video-gen-api/app/cmd/reconcile_llm_billing.py new file mode 100644 index 00000000..a3e0513b --- /dev/null +++ b/video-gen-api/app/cmd/reconcile_llm_billing.py @@ -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())) diff --git a/video-gen-api/app/cmd/reconcile_user_credits.py b/video-gen-api/app/cmd/reconcile_user_credits.py new file mode 100644 index 00000000..238b931e --- /dev/null +++ b/video-gen-api/app/cmd/reconcile_user_credits.py @@ -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())) diff --git a/video-gen-api/app/dependencies.py b/video-gen-api/app/dependencies.py index 7299a4a5..6ff0b397 100644 --- a/video-gen-api/app/dependencies.py +++ b/video-gen-api/app/dependencies.py @@ -6,6 +6,7 @@ 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 security = HTTPBearer(auto_error=False) @@ -53,6 +54,7 @@ async def get_current_user_allow_password_pending( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已禁用", ) + attach_credit_snapshot(user, await get_available_credits(db, user.id)) return user @@ -92,6 +94,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 diff --git a/video-gen-api/app/enums/celery_queue.py b/video-gen-api/app/enums/celery_queue.py index 47242ef9..edb25e14 100644 --- a/video-gen-api/app/enums/celery_queue.py +++ b/video-gen-api/app/enums/celery_queue.py @@ -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" DEFAULT = "default" @@ -43,3 +44,4 @@ 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" diff --git a/video-gen-api/app/enums/credit_balance.py b/video-gen-api/app/enums/credit_balance.py new file mode 100644 index 00000000..246ce476 --- /dev/null +++ b/video-gen-api/app/enums/credit_balance.py @@ -0,0 +1,46 @@ +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, +} + + +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" + + +class CreditBalanceStatus(StrEnum): + SCHEDULED = "scheduled" + ACTIVE = "active" + CONSUMED = "consumed" + EXPIRED = "expired" + REVOKED = "revoked" + CANCELLED = "cancelled" + + +class CreditAllocationAction(StrEnum): + GRANT = "grant" + CONSUME = "consume" + REFUND_AVAILABLE = "refund_available" + REFUND_EXPIRED = "refund_expired" + EXPIRE = "expire" + REVOKE = "revoke" + UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out" + UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in" diff --git a/video-gen-api/app/enums/credit_product.py b/video-gen-api/app/enums/credit_product.py new file mode 100644 index 00000000..3e89603b --- /dev/null +++ b/video-gen-api/app/enums/credit_product.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from enum import StrEnum + + +class CreditProductType(StrEnum): + SUBSCRIPTION = "subscription" + CREDIT_ADDON = "credit_addon" + + +class SubscriptionBillingCycle(StrEnum): + MONTHLY = "monthly" + QUARTERLY = "quarterly" + YEARLY = "yearly" + + +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" + + +class ProductPriceType(StrEnum): + FIRST_PURCHASE = "first_purchase" + REGULAR = "regular" + ACTIVITY = "activity" + UPGRADE = "upgrade" diff --git a/video-gen-api/app/enums/credit_record.py b/video-gen-api/app/enums/credit_record.py index 389c9674..bd74076e 100644 --- a/video-gen-api/app/enums/credit_record.py +++ b/video-gen-api/app/enums/credit_record.py @@ -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,12 +115,19 @@ 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.PRE_DEDUCT.value: "固定预扣", CreditRecordAction.REFUND.value: "真实退款", CreditRecordAction.HOLD.value: "预扣占用", CreditRecordAction.HOLD_RELEASE.value: "预扣释放", @@ -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: "历史未知", } diff --git a/video-gen-api/app/enums/credit_subscription.py b/video-gen-api/app/enums/credit_subscription.py new file mode 100644 index 00000000..75f2248d --- /dev/null +++ b/video-gen-api/app/enums/credit_subscription.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from enum import StrEnum + + +class CreditSubscriptionStatus(StrEnum): + PENDING = "pending" + ACTIVE = "active" + EXPIRED = "expired" + UPGRADED = "upgraded" + CANCELLED = "cancelled" + REFUNDED = "refunded" + UPGRADE_RECONCILE_FAILED = "upgrade_reconcile_failed" + + +class CreditSubscriptionPeriodStatus(StrEnum): + SCHEDULED = "scheduled" + UPGRADE_RESERVED = "upgrade_reserved" + GRANTED = "granted" + CANCELLED_BY_UPGRADE = "cancelled_by_upgrade" + REVOKED_BY_UPGRADE = "revoked_by_upgrade" + CANCELLED = "cancelled" + EXPIRED = "expired" diff --git a/video-gen-api/app/enums/llm_billing.py b/video-gen-api/app/enums/llm_billing.py index 4b1f0626..a4b06ad9 100644 --- a/video-gen-api/app/enums/llm_billing.py +++ b/video-gen-api/app/enums/llm_billing.py @@ -3,49 +3,54 @@ 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 = "succeeded" # 历史兼容别名 + + + +class LlmBillingExecutionStatus(StrEnum): + 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" + PRE_DEDUCT_START = "LLM_PRE_DEDUCT_START" + PRE_DEDUCT_SUCCESS = "LLM_PRE_DEDUCT_SUCCESS" + PRE_DEDUCT_INSUFFICIENT = "LLM_PRE_DEDUCT_INSUFFICIENT" + PRE_DEDUCT_CONFIG_INVALID = "LLM_PRE_DEDUCT_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 +58,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分析", +} diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py index 143ee16d..1edf76f2 100644 --- a/video-gen-api/app/main.py +++ b/video-gen-api/app/main.py @@ -138,7 +138,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 +153,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 +175,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 +428,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 +462,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 diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index 25a6481a..1f48bea5 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -17,6 +17,11 @@ 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, +) +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 @@ -44,7 +49,10 @@ __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", + "LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt", + "OperationLog", "ContactRequest", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask", "GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat", "UserResourceCapacityConfig", diff --git a/video-gen-api/app/models/credit/__init__.py b/video-gen-api/app/models/credit/__init__.py new file mode 100644 index 00000000..b0a32566 --- /dev/null +++ b/video-gen-api/app/models/credit/__init__.py @@ -0,0 +1,13 @@ +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 + +__all__ = [ + "CreditRecordAllocation", + "UserCreditBalance", + "CreditProduct", + "UserCreditSubscription", + "UserCreditSubscriptionPeriod", +] diff --git a/video-gen-api/app/models/credit/allocation.py b/video-gen-api/app/models/credit/allocation.py new file mode 100644 index 00000000..ab2c72bb --- /dev/null +++ b/video-gen-api/app/models/credit/allocation.py @@ -0,0 +1,47 @@ +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.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"), + ) + + 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 + ) + 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) + source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False) + source_id_snapshot: Mapped[str | None] = mapped_column(String(64), 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) diff --git a/video-gen-api/app/models/credit/balance.py b/video-gen-api/app/models/credit/balance.py new file mode 100644 index 00000000..e4512b0c --- /dev/null +++ b/video-gen-api/app/models/credit/balance.py @@ -0,0 +1,94 @@ +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 +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"), + Index( + "ix_user_credit_balances_spendable", + "user_id", + "credit_level_rank", + "expires_at", + "valid_from", + "id", + postgresql_where=text("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 + ) + 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) diff --git a/video-gen-api/app/models/credit/product.py b/video-gen-api/app/models/credit/product.py new file mode 100644 index 00000000..2027d7cf --- /dev/null +++ b/video-gen-api/app/models/credit/product.py @@ -0,0 +1,84 @@ +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, TimestampMixin + + +class CreditProduct(Base, TimestampMixin): + __tablename__ = "credit_products" + __table_args__ = ( + Index("uq_credit_products_code", "product_code", unique=True), + Index("ix_credit_products_public", "product_type", "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 = '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 = 1 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" + ) + + # 积分增值包字段;当前固定一个自然月有效。 + 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 == CreditProductType.SUBSCRIPTION.value + + @property + def is_credit_addon(self) -> bool: + return self.product_type == CreditProductType.CREDIT_ADDON.value diff --git a/video-gen-api/app/models/credit/subscription.py b/video-gen-api/app/models/credit/subscription.py new file mode 100644 index 00000000..96c39d1c --- /dev/null +++ b/video-gen-api/app/models/credit/subscription.py @@ -0,0 +1,54 @@ +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_current", "user_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), + ) + + 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 + ) + 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) + 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) + grant_count: Mapped[int] = mapped_column(nullable=False) + granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0") + paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False) + product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False) + source_subscription_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True + ) + upgrade_order_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True + ) diff --git a/video-gen-api/app/models/credit/subscription_period.py b/video-gen-api/app/models/credit/subscription_period.py new file mode 100644 index 00000000..01233d0a --- /dev/null +++ b/video-gen-api/app/models/credit/subscription_period.py @@ -0,0 +1,45 @@ +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("ix_user_credit_subscription_periods_upgrade", "upgrade_order_id", "status"), + Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True), + ) + + 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) + upgrade_order_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True + ) + reserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/video-gen-api/app/models/credit_record.py b/video-gen-api/app/models/credit_record.py index 3f0928b3..a7cba4d1 100644 --- a/video-gen-api/app/models/credit_record.py +++ b/video-gen-api/app/models/credit_record.py @@ -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) diff --git a/video-gen-api/app/models/llm_billing/__init__.py b/video-gen-api/app/models/llm_billing/__init__.py new file mode 100644 index 00000000..15f0fb05 --- /dev/null +++ b/video-gen-api/app/models/llm_billing/__init__.py @@ -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"] diff --git a/video-gen-api/app/models/llm_billing/call_attempt.py b/video-gen-api/app/models/llm_billing/call_attempt.py new file mode 100644 index 00000000..16b49182 --- /dev/null +++ b/video-gen-api/app/models/llm_billing/call_attempt.py @@ -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) diff --git a/video-gen-api/app/models/llm_billing/execution.py b/video-gen-api/app/models/llm_billing/execution.py new file mode 100644 index 00000000..32ba541b --- /dev/null +++ b/video-gen-api/app/models/llm_billing/execution.py @@ -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) diff --git a/video-gen-api/app/models/llm_billing/policy.py b/video-gen-api/app/models/llm_billing/policy.py new file mode 100644 index 00000000..c627bab2 --- /dev/null +++ b/video-gen-api/app/models/llm_billing/policy.py @@ -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) diff --git a/video-gen-api/app/models/payment_order.py b/video-gen-api/app/models/payment_order.py index c9da1ea4..059ffaf3 100644 --- a/video-gen-api/app/models/payment_order.py +++ b/video-gen-api/app/models/payment_order.py @@ -1,6 +1,9 @@ -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, JSON, Numeric, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -8,27 +11,40 @@ 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"), + ) 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) + amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False) + 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)) - status: Mapped[str] = mapped_column(String(16), default="pending") - paid_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True - ) + 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) - __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) + subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + source_subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + upgrade_period_ids_json: Mapped[list | None] = mapped_column(JSON, nullable=True) + target_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True) + deduction_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True) + payable_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=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) diff --git a/video-gen-api/app/models/recharge_package.py b/video-gen-api/app/models/recharge_package.py index 4e7e21f0..237c7b49 100644 --- a/video-gen-api/app/models/recharge_package.py +++ b/video-gen-api/app/models/recharge_package.py @@ -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"] diff --git a/video-gen-api/app/models/user.py b/video-gen-api/app/models/user.py index 57801686..0681ada4 100644 --- a/video-gen-api/app/models/user.py +++ b/video-gen-api/app/models/user.py @@ -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,25 @@ 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 ) + @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 diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py index 95e58d1a..d185ce44 100644 --- a/video-gen-api/app/schemas/admin.py +++ b/video-gen-api/app/schemas/admin.py @@ -176,6 +176,24 @@ class AdminCreditRecordSummaryOut(BaseModel): output_tokens: int = 0 + + +class AdminCreditRecordAllocationOut(BaseModel): + id: str + credit_balance_id: str + source_allocation_id: str | None = None + allocation_action: str + amount: float + credit_level: str + source_type: str + source_id: str | None = None + valid_from: str | None = None + expires_at: str | None = None + unspent_before: float = 0.0 + unspent_after: float = 0.0 + consumed_before: float = 0.0 + consumed_after: float = 0.0 + class AdminCreditRecordOut(BaseModel): id: str user_id: str @@ -192,6 +210,8 @@ class AdminCreditRecordOut(BaseModel): record_type: str record_type_label: str | None = None amount: float + balance_delta: float = 0.0 + expired_amount: float = 0.0 balance_after: float description: str | None = None related_id: str | None = None @@ -212,6 +232,8 @@ class AdminCreditRecordOut(BaseModel): media_type_label: str | None = None billing_scene: str | None = None billing_scene_label: str | None = None + scene_name_snapshot: str | None = None + request_time: str | None = None source_module: str | None = None source_module_label: str | None = None source_project_id: str | None = None @@ -222,6 +244,10 @@ class AdminCreditRecordOut(BaseModel): input_tokens: int = 0 output_tokens: int = 0 total_tokens: int = 0 + llm_call_count: int = 0 + llm_success_call_count: int = 0 + llm_failed_call_count: int = 0 + allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list) engine_type: str | None = None engine_id: str | None = None engine_name: str | None = None diff --git a/video-gen-api/app/schemas/credit.py b/video-gen-api/app/schemas/credit.py index 040bf9a6..bdaaafaf 100644 --- a/video-gen-api/app/schemas/credit.py +++ b/video-gen-api/app/schemas/credit.py @@ -1,18 +1,36 @@ -from pydantic import BaseModel +from __future__ import annotations -from app.schemas.common import NaiveDatetime +from datetime import datetime + +from pydantic import BaseModel class CreditRecordOut(BaseModel): id: str type: str amount: float + balance_delta: float = 0 + expired_amount: float = 0 + balance_after: float description: str - created_at: NaiveDatetime + billing_scene: str | None = None + scene_name_snapshot: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + llm_call_count: int | None = None + llm_success_call_count: int | None = None + llm_failed_call_count: int | None = None + created_at: datetime model_config = {"from_attributes": True} class CreditBalanceOut(BaseModel): credits: float + available_credits: float + next_expiring_credits: float = 0 + next_expires_at: datetime | None = None + next_last_usable_at: datetime | None = None records: list[CreditRecordOut] + total: int = 0 diff --git a/video-gen-api/app/schemas/credit_balance.py b/video-gen-api/app/schemas/credit_balance.py new file mode 100644 index 00000000..48f90aa1 --- /dev/null +++ b/video-gen-api/app/schemas/credit_balance.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class CreditBalanceItemOut(BaseModel): + id: str + credit_level: str + source_type: str + source_id: str | None = None + product_id: str | None = None + payment_order_id: str | None = None + subscription_id: str | None = None + subscription_period_id: str | None = None + grant_amount: float + unspent_amount: float + consumed_amount: float + expired_amount: float + revoked_amount: float + valid_from: datetime + expires_at: datetime + last_usable_at: datetime + status: str + created_at: datetime + + +class CreditBalanceSummaryOut(BaseModel): + credits: float + available_credits: float + next_expiring_credits: float + next_expires_at: datetime | None = None + next_last_usable_at: datetime | None = None + + +class AdminCreditGrantRequest(BaseModel): + amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01) + description: str = Field(..., min_length=1, max_length=256) + valid_from: datetime | None = None + validity_unit: str = Field(default="month", pattern="^(day|month)$") + validity_value: int = Field(default=1, ge=1, le=120) + credit_level: str = Field(default="general", pattern="^(promotional|general)$") + + +class AdminCreditDeductRequest(BaseModel): + amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01) + description: str = Field(..., min_length=1, max_length=256) diff --git a/video-gen-api/app/schemas/credit_product.py b/video-gen-api/app/schemas/credit_product.py new file mode 100644 index 00000000..a2a9c947 --- /dev/null +++ b/video-gen-api/app/schemas/credit_product.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + + +class CreditProductBase(BaseModel): + product_code: str = Field(..., min_length=1, max_length=64) + product_type: Literal["subscription", "credit_addon"] + name: str = Field(..., min_length=1, max_length=96) + description: str | None = Field(default=None, max_length=512) + features: list[str] = Field(default_factory=list) + + tier_code: str | None = Field(default=None, max_length=32) + tier_rank: int | None = Field(default=None, ge=1, le=999) + billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None + monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99) + first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99) + regular_price: float | None = Field(default=None, ge=0, le=999999999.99) + activity_price: float | None = Field(default=None, ge=0, le=999999999.99) + activity_start_at: datetime | None = None + activity_end_at: datetime | None = None + renewal_enabled: bool = True + + price: float = Field(default=0, ge=0, le=999999999.99) + grant_credits: float | None = Field(default=None, gt=0, le=999999999.99) + credit_level: Literal["promotional", "general"] = "general" + currency: str = Field(default="CNY", min_length=1, max_length=8) + is_active: bool = True + sort_order: int = Field(default=0, ge=-999999, le=999999) + + @model_validator(mode="after") + def validate_product_fields(self): + if self.product_type == "subscription": + required = { + "tier_code": self.tier_code, + "tier_rank": self.tier_rank, + "billing_cycle": self.billing_cycle, + "monthly_grant_credits": self.monthly_grant_credits, + "first_purchase_price": self.first_purchase_price, + "regular_price": self.regular_price, + } + missing = [name for name, value in required.items() if value is None] + if missing: + raise ValueError(f"订阅套餐缺少字段: {', '.join(missing)}") + if self.activity_price is not None: + if not self.activity_start_at or not self.activity_end_at: + raise ValueError("配置活动价时必须同时配置活动开始和结束时间") + if self.activity_end_at <= self.activity_start_at: + raise ValueError("活动结束时间必须晚于开始时间") + else: + if self.grant_credits is None: + raise ValueError("积分增值包必须配置积分数量") + if self.price < 0: + raise ValueError("增值包价格不能小于0") + return self + + +class CreditProductCreate(CreditProductBase): + pass + + +class CreditProductUpdate(BaseModel): + product_code: str | None = Field(default=None, min_length=1, max_length=64) + name: str | None = Field(default=None, min_length=1, max_length=96) + description: str | None = Field(default=None, max_length=512) + features: list[str] | None = None + tier_code: str | None = Field(default=None, max_length=32) + tier_rank: int | None = Field(default=None, ge=1, le=999) + billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None + monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99) + first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99) + regular_price: float | None = Field(default=None, ge=0, le=999999999.99) + activity_price: float | None = Field(default=None, ge=0, le=999999999.99) + activity_start_at: datetime | None = None + activity_end_at: datetime | None = None + renewal_enabled: bool | None = None + price: float | None = Field(default=None, ge=0, le=999999999.99) + grant_credits: float | None = Field(default=None, gt=0, le=999999999.99) + credit_level: Literal["promotional", "general"] | None = None + currency: str | None = Field(default=None, min_length=1, max_length=8) + is_active: bool | None = None + sort_order: int | None = Field(default=None, ge=-999999, le=999999) + + +class CreditProductRenewalUpdate(BaseModel): + renewal_enabled: bool + + +class CreditProductOut(BaseModel): + id: str + product_code: str + product_type: str + name: str + description: str | None = None + features: list[str] = Field(default_factory=list) + tier_code: str | None = None + tier_rank: int | None = None + billing_cycle: str | None = None + monthly_grant_credits: float = 0 + grant_count: int = 1 + first_purchase_price: float = 0 + regular_price: float = 0 + activity_price: float | None = None + activity_start_at: datetime | None = None + activity_end_at: datetime | None = None + renewal_enabled: bool = True + grant_credits: float = 0 + validity_months: int | None = None + price: float + current_price: float + target_price: float | None = None + deduction_amount: float = 0 + price_type: str | None = None + credit_level: str + currency: str + is_active: bool + sort_order: int + can_purchase: bool | None = None + can_upgrade: bool = False + unavailable_reason: str | None = None + + +class CreditProductCatalogOut(BaseModel): + subscription_products: list[CreditProductOut] + credit_addons: list[CreditProductOut] + first_purchase_available: bool + current_subscription: dict | None = None diff --git a/video-gen-api/app/schemas/credit_subscription.py b/video-gen-api/app/schemas/credit_subscription.py new file mode 100644 index 00000000..c7696ec0 --- /dev/null +++ b/video-gen-api/app/schemas/credit_subscription.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel + + +class CreditSubscriptionPeriodOut(BaseModel): + id: str + sequence: int + scheduled_at: datetime + valid_from: datetime + expires_at: datetime + grant_credits: float + allocated_paid_amount: float + status: str + issued_balance_id: str | None = None + issued_at: datetime | None = None + + model_config = {"from_attributes": True} + + +class CreditSubscriptionOut(BaseModel): + id: str + product_id: str | None = None + payment_order_id: str + status: str + purchase_scene: str + tier_code: str + tier_rank: int + billing_cycle: str + anchor_at: datetime + start_at: datetime + expires_at: datetime + next_grant_at: datetime | None = None + monthly_grant_credits_snapshot: float + grant_count: int + granted_count: int + paid_amount_snapshot: float + source_subscription_id: str | None = None + periods: list[CreditSubscriptionPeriodOut] = [] + + model_config = {"from_attributes": True} diff --git a/video-gen-api/app/schemas/llm_billing.py b/video-gen-api/app/schemas/llm_billing.py new file mode 100644 index 00000000..e97c3364 --- /dev/null +++ b/video-gen-api/app/schemas/llm_billing.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class LlmBillingPolicyCreate(BaseModel): + scene_code: str = Field(..., min_length=1, max_length=64) + scene_name: str | None = Field(default=None, min_length=1, max_length=128) + pre_deduct_credits: float = Field(..., ge=0, le=999999999.99, multiple_of=0.01) + is_active: bool = True + + +class LlmBillingPolicyUpdate(BaseModel): + scene_name: str | None = Field(default=None, min_length=1, max_length=128) + pre_deduct_credits: float | None = Field(default=None, ge=0, le=999999999.99, multiple_of=0.01) + is_active: bool | None = None + + +class LlmBillingPolicyOut(BaseModel): + id: str + scene_code: str + scene_name: str + pre_deduct_credits: float + is_active: bool + version: int + created_by: str | None = None + updated_by: str | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class LlmCallAttemptOut(BaseModel): + id: str + call_sequence: int + retry_sequence: int + model_config_id: str | None = None + model_name_snapshot: str | None = None + provider_snapshot: str | None = None + provider_request_id: str | None = None + request_started_at: datetime + response_received_at: datetime | None = None + duration_ms: int | None = None + status: str + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + http_status: int | None = None + provider_error_code: str | None = None + error_message: str | None = None + token_unavailable_reason: str | None = None + postprocess_status: str | None = None + postprocess_error: str | None = None + + model_config = {"from_attributes": True} + + +class LlmBillingExecutionOut(BaseModel): + id: str + user_id: str + scene_code: str + scene_name_snapshot: str + owner_type: str + owner_id: str + business_attempt_no: int + model_config_id: str | None = None + model_name_snapshot: str | None = None + provider_snapshot: str | None = None + request_time: datetime + pre_deduct_credits: float + status: str + total_call_count: int + successful_call_count: int + failed_call_count: int + total_input_tokens: int + total_output_tokens: int + total_tokens: int + refund_available_credits: float + refund_expired_credits: float + final_error_message: str | None = None + completed_at: datetime | None = None + refunded_at: datetime | None = None + calls: list[LlmCallAttemptOut] = Field(default_factory=list) + + model_config = {"from_attributes": True} diff --git a/video-gen-api/app/schemas/payment.py b/video-gen-api/app/schemas/payment.py index 6ba5fc8c..80f81170 100644 --- a/video-gen-api/app/schemas/payment.py +++ b/video-gen-api/app/schemas/payment.py @@ -1,10 +1,13 @@ -from pydantic import BaseModel +from __future__ import annotations + from datetime import datetime +from pydantic import BaseModel, Field + class RechargeRequest(BaseModel): - plan: str # package id - method: str = "wechat" # "wechat" or "alipay" + plan: str = Field(..., description="积分商品ID;兼容旧字段名plan") + method: str = "wechat" class PaymentOrderOut(BaseModel): @@ -14,7 +17,16 @@ class PaymentOrderOut(BaseModel): credits: float payment_method: str status: str - qr_url: str | None = None # Alipay QR code URL (transient, not persisted) + product_id: str | None = None + product_type: str | None = None + purchase_scene: str | None = None + price_type: str | None = None + product_name_snapshot: str | None = None + target_price_snapshot: float | None = None + deduction_amount_snapshot: float | None = None + payable_amount_snapshot: float | None = None + fulfillment_status: str | None = None + qr_url: str | None = None created_at: datetime paid_at: datetime | None = None diff --git a/video-gen-api/app/services/admin_credit_record_service.py b/video-gen-api/app/services/admin_credit_record_service.py index 1223b7c4..0cf88981 100644 --- a/video-gen-api/app/services/admin_credit_record_service.py +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -20,6 +20,7 @@ from app.enums.credit_record import ( from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType from app.enums.team import TEAM_UNASSIGNED_VALUE from app.models.chat_generation_task import ChatGenerationTask +from app.models.credit.allocation import CreditRecordAllocation from app.models.credit_record import CreditRecord from app.models.generation_record import GenerationRecord from app.models.module_generation_project import ModuleGenerationProject @@ -165,7 +166,12 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di return deleted_map -def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[tuple[str, str], tuple[bool, str | None]]) -> dict[str, Any]: +def _record_to_item( + record: CreditRecord, + user: User | None, + deleted_map: dict[tuple[str, str], tuple[bool, str | None]], + allocation_map: dict[str, list[dict[str, Any]]], +) -> dict[str, Any]: owner_deleted = False owner_deleted_at = None if record.owner_type and record.owner_id: @@ -189,6 +195,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t "record_type": record.type, "record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type), "amount": _round2(record.amount), + "balance_delta": _round2(record.balance_delta), + "expired_amount": _round2(record.expired_amount), "balance_after": _round2(record.balance_after), "description": record.description, "related_id": record.related_id, @@ -209,6 +217,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t "media_type_label": _label(CREDIT_RECORD_MEDIA_TYPE_LABELS, record.media_type), "billing_scene": record.billing_scene, "billing_scene_label": _label(CREDIT_RECORD_BILLING_SCENE_LABELS, record.billing_scene), + "scene_name_snapshot": record.scene_name_snapshot, + "request_time": _iso(record.request_time), "source_module": record.source_module, "source_module_label": _label(CREDIT_RECORD_SOURCE_MODULE_LABELS, record.source_module), "source_project_id": record.source_project_id, @@ -219,6 +229,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t "input_tokens": record.input_tokens or 0, "output_tokens": record.output_tokens or 0, "total_tokens": record.total_tokens or 0, + "llm_call_count": record.llm_call_count or 0, + "llm_success_call_count": record.llm_success_call_count or 0, + "llm_failed_call_count": record.llm_failed_call_count or 0, + "allocations": allocation_map.get(record.id, []), "engine_type": record.engine_type, "engine_id": record.engine_id, "engine_name": record.engine_name, @@ -285,7 +299,32 @@ async def list_admin_credit_records( rows = result.all() records = [row[0] for row in rows] deleted_map = await _load_deleted_map(db, records) - items = [_record_to_item(record, user, deleted_map) for record, user in rows] + record_ids = [record.id for record in records] + allocation_map: dict[str, list[dict[str, Any]]] = {record_id: [] for record_id in record_ids} + if record_ids: + allocation_result = await db.execute( + select(CreditRecordAllocation) + .where(CreditRecordAllocation.credit_record_id.in_(record_ids)) + .order_by(CreditRecordAllocation.credit_record_id.asc(), CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc()) + ) + for allocation in allocation_result.scalars().all(): + allocation_map.setdefault(allocation.credit_record_id, []).append({ + "id": allocation.id, + "credit_balance_id": allocation.credit_balance_id, + "source_allocation_id": allocation.source_allocation_id, + "allocation_action": allocation.allocation_action, + "amount": _round2(allocation.amount), + "credit_level": allocation.credit_level_snapshot, + "source_type": allocation.source_type_snapshot, + "source_id": allocation.source_id_snapshot, + "valid_from": _iso(allocation.valid_from_snapshot), + "expires_at": _iso(allocation.expires_at_snapshot), + "unspent_before": _round2(allocation.unspent_before), + "unspent_after": _round2(allocation.unspent_after), + "consumed_before": _round2(allocation.consumed_before), + "consumed_after": _round2(allocation.consumed_after), + }) + items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows] summary_query = select( func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), diff --git a/video-gen-api/app/services/credit/__init__.py b/video-gen-api/app/services/credit/__init__.py new file mode 100644 index 00000000..3d4d9f09 --- /dev/null +++ b/video-gen-api/app/services/credit/__init__.py @@ -0,0 +1,35 @@ +from app.services.credit.expiration_service import ( + archive_expired_balances_batch, + archive_expired_user_balances, +) +from app.services.credit.ledger_service import ( + CreditMutationResult, + CreditRefundResult, + deduct_credits, + grant_credits, + refund_consumption, + revoke_balances, +) +from app.services.credit.query_service import ( + CreditBalanceSummary, + attach_credit_snapshot, + get_available_credits, + get_balance_summary, + get_user_credit_map, +) + +__all__ = [ + "CreditMutationResult", + "CreditRefundResult", + "CreditBalanceSummary", + "deduct_credits", + "grant_credits", + "refund_consumption", + "revoke_balances", + "archive_expired_balances_batch", + "archive_expired_user_balances", + "attach_credit_snapshot", + "get_available_credits", + "get_balance_summary", + "get_user_credit_map", +] diff --git a/video-gen-api/app/services/credit/expiration_service.py b/video-gen-api/app/services/credit/expiration_service.py new file mode 100644 index 00000000..e85ac8d0 --- /dev/null +++ b/video-gen-api/app/services/credit/expiration_service.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from collections import Counter +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType +from app.models.credit.allocation import CreditRecordAllocation +from app.models.credit.balance import UserCreditBalance +from app.models.credit_record import CreditRecord +from app.services.credit.locking import acquire_user_credit_lock +from app.services.credit.query_service import get_available_credits +from app.services.credit.utils import to_credit_decimal, utc_now +from app.utils.id_gen import generate_id + + +async def archive_expired_user_balances( + db: AsyncSession, + *, + user_id: str, + request_time: datetime | None = None, + limit: int = 500, +) -> int: + checked_at = request_time or utc_now() + await acquire_user_credit_lock(db, user_id) + result = await db.execute( + select(UserCreditBalance) + .where( + UserCreditBalance.user_id == user_id, + UserCreditBalance.expires_at <= checked_at, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.expired_processed_at.is_(None), + UserCreditBalance.revoked_at.is_(None), + ) + .order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()) + .limit(max(1, limit)) + .with_for_update() + ) + balances = list(result.scalars().all()) + if not balances: + return 0 + + current_available = await get_available_credits(db, user_id, request_time=checked_at) + for balance in balances: + amount = to_credit_decimal(balance.unspent_amount) + if amount <= 0: + continue + before_consumed = to_credit_decimal(balance.consumed_amount) + record = CreditRecord( + id=generate_id(), + user_id=user_id, + type=CreditRecordType.EXPIRE.value, + amount=-amount, + balance_delta=Decimal("0.00"), + expired_amount=amount, + balance_after=current_available, + description=f"积分到期:{float(amount):.2f}", + related_id=balance.id, + request_time=checked_at, + biz_key=f"credit-balance:{balance.id}:expire", + billing_scene=CreditRecordBillingScene.CREDIT_EXPIRE.value, + credit_level_snapshot=balance.credit_level, + ) + db.add(record) + await db.flush() + db.add( + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=balance.id, + user_id=user_id, + allocation_action=CreditAllocationAction.EXPIRE.value, + amount=amount, + request_time=checked_at, + credit_level_snapshot=balance.credit_level, + source_type_snapshot=balance.source_type, + source_id_snapshot=balance.source_id, + valid_from_snapshot=balance.valid_from, + expires_at_snapshot=balance.expires_at, + unspent_before=amount, + unspent_after=Decimal("0.00"), + consumed_before=before_consumed, + consumed_after=before_consumed, + ) + ) + balance.unspent_amount = Decimal("0.00") + balance.expired_amount = to_credit_decimal(balance.expired_amount) + amount + balance.expired_processed_at = checked_at + balance.status = CreditBalanceStatus.EXPIRED.value + await db.flush() + return len(balances) + + +async def list_expired_balance_user_limits( + db: AsyncSession, + *, + request_time: datetime, + batch_size: int = 500, +) -> list[tuple[str, int]]: + result = await db.execute( + select(UserCreditBalance.id, UserCreditBalance.user_id) + .where( + UserCreditBalance.expires_at <= request_time, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.expired_processed_at.is_(None), + UserCreditBalance.revoked_at.is_(None), + ) + .order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()) + .limit(max(1, batch_size)) + ) + selected = [(str(row.id), str(row.user_id)) for row in result.all()] + per_user_limit = Counter(user_id for _, user_id in selected) + return sorted(per_user_limit.items(), key=lambda item: item[0]) + + +async def archive_expired_balances_batch( + db: AsyncSession, + *, + request_time: datetime | None = None, + batch_size: int = 500, +) -> int: + checked_at = request_time or utc_now() + user_limits = await list_expired_balance_user_limits( + db, request_time=checked_at, batch_size=batch_size + ) + processed = 0 + for user_id, user_limit in user_limits: + processed += await archive_expired_user_balances( + db, + user_id=user_id, + request_time=checked_at, + limit=user_limit, + ) + return processed diff --git a/video-gen-api/app/services/credit/ledger_service.py b/video-gen-api/app/services/credit/ledger_service.py new file mode 100644 index 00000000..9e08bbba --- /dev/null +++ b/video-gen-api/app/services/credit/ledger_service.py @@ -0,0 +1,791 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Iterable + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_balance import ( + CREDIT_LEVEL_SORT, + CreditAllocationAction, + CreditBalanceSourceType, + CreditBalanceStatus, + CreditLevel, +) +from app.enums.credit_record import ( + CreditRecordAction, + CreditRecordBillingScene, + CreditRecordType, +) +from app.enums.common import BillingBlockEventEnum +from app.models.credit.allocation import CreditRecordAllocation +from app.models.credit.balance import UserCreditBalance +from app.models.credit_record import CreditRecord +from app.models.user import User +from app.services.credit.locking import acquire_user_credit_lock +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.services.credit.utils import to_credit_decimal, to_float, utc_now +from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot +from app.services.operation_log_service import log_operation_event +from app.utils.exceptions import InsufficientCreditsError +from app.utils.id_gen import generate_id + + +@dataclass(slots=True) +class CreditMutationResult: + user: User + record: CreditRecord | None + created: bool + amount: float + balance_before: float + balance_after: float + refund_available: float = 0.0 + refund_expired: float = 0.0 + + +@dataclass(slots=True, frozen=True) +class _RefundAllocationSource: + original_allocation: CreditRecordAllocation + credit_balance_id: str + amount: Decimal + + +@dataclass(slots=True, frozen=True) +class CreditRefundResult: + records: tuple[CreditRecord, ...] + created: bool + total_amount: Decimal + available_amount: Decimal + expired_amount: Decimal + balance_before: Decimal + balance_after: Decimal + + +async def _load_user(db: AsyncSession, user_id: str) -> User: + result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = result.scalar_one_or_none() + if user is None: + raise ValueError("User not found") + return user + + +async def _record_meta_kwargs( + db: AsyncSession, + user: User, + record_meta: CreditRecordMeta | dict | None, +) -> dict: + if isinstance(record_meta, CreditRecordMeta): + populated = await with_user_snapshot(db, record_meta, user.id, user=user) + return populated.to_record_kwargs() + if isinstance(record_meta, dict): + return {key: value for key, value in record_meta.items() if value is not None} + return {} + + +async def _find_record_by_biz_key( + db: AsyncSession, + *, + user_id: str, + biz_key: str | None, +) -> CreditRecord | None: + if not biz_key: + return None + result = await db.execute( + select(CreditRecord) + .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def grant_credits( + db: AsyncSession, + *, + user_id: str, + amount: Decimal | float | int, + description: str, + source_type: str, + valid_from: datetime | None = None, + expires_at: datetime | None = None, + credit_level: str = CreditLevel.GENERAL.value, + source_id: str | None = None, + product_id: str | None = None, + payment_order_id: str | None = None, + subscription_id: str | None = None, + subscription_period_id: str | None = None, + related_id: str | None = None, + record_type: str = CreditRecordType.RECHARGE.value, + biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, + metadata_json: dict | None = None, + request_time: datetime | None = None, +) -> CreditMutationResult: + grant_amount = to_credit_decimal(amount) + checked_at = request_time or utc_now() + starts_at = valid_from or checked_at + ends_at = expires_at or add_natural_months(starts_at, 1) + + await acquire_user_credit_lock(db, user_id) + user = await _load_user(db, user_id) + before = await get_available_credits(db, user_id, request_time=checked_at) + + existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) + if existing is not None: + attach_credit_snapshot(user, before) + return CreditMutationResult( + user=user, + record=existing, + created=False, + amount=abs(to_float(existing.amount)), + balance_before=to_float(before), + balance_after=to_float(before), + ) + if grant_amount <= 0: + attach_credit_snapshot(user, before) + return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before)) + if ends_at <= starts_at: + raise ValueError("积分过期时间必须晚于生效时间") + + meta_kwargs = await _record_meta_kwargs(db, user, record_meta) + after = before + grant_amount if starts_at <= checked_at < ends_at else before + record = CreditRecord( + id=generate_id(), + user_id=user_id, + type=record_type, + amount=grant_amount, + balance_delta=(grant_amount if starts_at <= checked_at < ends_at else Decimal("0.00")), + expired_amount=Decimal("0.00"), + balance_after=after, + description=description, + related_id=related_id, + request_time=checked_at, + biz_key=biz_key, + credit_level_snapshot=credit_level, + **meta_kwargs, + ) + db.add(record) + await db.flush() + + balance = UserCreditBalance( + id=generate_id(), + user_id=user_id, + credit_level=credit_level, + credit_level_rank=CREDIT_LEVEL_SORT.get(credit_level, CREDIT_LEVEL_SORT[CreditLevel.GENERAL.value]), + source_type=source_type, + source_id=source_id, + product_id=product_id, + payment_order_id=payment_order_id, + subscription_id=subscription_id, + subscription_period_id=subscription_period_id, + grant_record_id=record.id, + grant_amount=grant_amount, + unspent_amount=grant_amount, + consumed_amount=Decimal("0.00"), + expired_amount=Decimal("0.00"), + revoked_amount=Decimal("0.00"), + valid_from=starts_at, + expires_at=ends_at, + status=CreditBalanceStatus.ACTIVE.value, + biz_key=f"{biz_key or record.id}:balance", + metadata_json=metadata_json, + ) + db.add(balance) + await db.flush() + + allocation = CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=balance.id, + user_id=user_id, + allocation_action=CreditAllocationAction.GRANT.value, + amount=grant_amount, + request_time=checked_at, + credit_level_snapshot=credit_level, + source_type_snapshot=source_type, + source_id_snapshot=source_id, + valid_from_snapshot=starts_at, + expires_at_snapshot=ends_at, + unspent_before=Decimal("0.00"), + unspent_after=grant_amount, + consumed_before=Decimal("0.00"), + consumed_after=Decimal("0.00"), + ) + db.add(allocation) + attach_credit_snapshot(user, after) + await db.flush() + log_operation_event( + domain="billing", + module="dynamic_credit", + event_type="CREDIT_GRANTED", + event_status="success", + source="app.services.credit.ledger_service.grant_credits", + user_id=user_id, + task_id=related_id, + message="动态积分发放完成", + detail={ + "record_id": record.id, + "balance_id": balance.id, + "amount": to_float(grant_amount), + "source_type": source_type, + "credit_level": credit_level, + "valid_from": starts_at.isoformat(), + "expires_at": ends_at.isoformat(), + "biz_key": biz_key, + }, + ) + return CreditMutationResult( + user=user, + record=record, + created=True, + amount=to_float(grant_amount), + balance_before=to_float(before), + balance_after=to_float(after), + ) + + +async def deduct_credits( + db: AsyncSession, + *, + user_id: str, + amount: Decimal | float | int, + description: str, + related_id: str | None = None, + biz_key: str | None = None, + refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, + record_type: str = CreditRecordType.CONSUME.value, + create_zero_record: bool = False, + request_time: datetime | None = None, +) -> CreditMutationResult: + consume_amount = to_credit_decimal(amount) + checked_at = request_time or utc_now() + + await acquire_user_credit_lock(db, user_id) + user = await _load_user(db, user_id) + before = await get_available_credits(db, user_id, request_time=checked_at) + + existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) + if existing is not None: + attach_credit_snapshot(user, before) + return CreditMutationResult( + user=user, + record=existing, + created=False, + amount=abs(to_float(existing.amount)), + balance_before=to_float(before), + balance_after=to_float(before), + ) + + if consume_amount <= 0 and not create_zero_record: + attach_credit_snapshot(user, before) + return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before)) + + if consume_amount > before: + log_operation_event( + domain="billing", + module="dynamic_credit", + event_type=BillingBlockEventEnum.INSUFFICIENT_CREDITS.value, + event_status="failed", + source="app.services.credit.ledger_service.deduct_credits", + user_id=user_id, + task_id=related_id, + message="有效积分不足,已在创建业务任务前同步拦截", + detail={ + "required_credits": to_float(consume_amount), + "available_credits": to_float(before), + "biz_key": biz_key, + "request_time": checked_at.isoformat(), + "description": description, + }, + ) + raise InsufficientCreditsError() + + result = await db.execute( + select(UserCreditBalance) + .where( + UserCreditBalance.user_id == user_id, + UserCreditBalance.valid_from <= checked_at, + UserCreditBalance.expires_at > checked_at, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.revoked_at.is_(None), + ) + .order_by( + UserCreditBalance.credit_level_rank.asc(), + UserCreditBalance.expires_at.asc(), + UserCreditBalance.valid_from.asc(), + UserCreditBalance.id.asc(), + ) + .with_for_update() + ) + balances = list(result.scalars().all()) + + remaining = consume_amount + allocations_data: list[tuple[UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = [] + for balance in balances: + if remaining <= 0: + break + available = to_credit_decimal(balance.unspent_amount) + if available <= 0: + continue + allocated = min(available, remaining) + before_unspent = available + before_consumed = to_credit_decimal(balance.consumed_amount) + after_unspent = before_unspent - allocated + after_consumed = before_consumed + allocated + balance.unspent_amount = after_unspent + balance.consumed_amount = after_consumed + if after_unspent == 0: + balance.status = CreditBalanceStatus.CONSUMED.value + allocations_data.append( + (balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed) + ) + remaining -= allocated + + if remaining > 0: + # 理论上用户级锁和前置汇总后不应发生;保留硬失败以避免部分扣除。 + raise RuntimeError("积分分摊不足,事务将回滚") + + meta_kwargs = await _record_meta_kwargs(db, user, record_meta) + after = before - consume_amount + record = CreditRecord( + id=generate_id(), + user_id=user_id, + type=record_type, + amount=-consume_amount, + balance_delta=-consume_amount, + expired_amount=Decimal("0.00"), + balance_after=after, + description=description, + related_id=related_id, + request_time=checked_at, + biz_key=biz_key, + refund_for_biz_key=refund_for_biz_key, + **meta_kwargs, + ) + db.add(record) + await db.flush() + + db.add_all( + [ + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=balance.id, + user_id=user_id, + allocation_action=CreditAllocationAction.CONSUME.value, + amount=allocated, + request_time=checked_at, + credit_level_snapshot=balance.credit_level, + source_type_snapshot=balance.source_type, + source_id_snapshot=balance.source_id, + valid_from_snapshot=balance.valid_from, + expires_at_snapshot=balance.expires_at, + unspent_before=before_unspent, + unspent_after=after_unspent, + consumed_before=before_consumed, + consumed_after=after_consumed, + ) + for balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed in allocations_data + ] + ) + attach_credit_snapshot(user, after) + await db.flush() + log_operation_event( + domain="billing", + module="dynamic_credit", + event_type="CREDIT_DEDUCTED", + event_status="success", + source="app.services.credit.ledger_service.deduct_credits", + user_id=user_id, + task_id=related_id, + message="动态积分同步扣除完成", + detail={ + "record_id": record.id, + "amount": to_float(consume_amount), + "balance_before": to_float(before), + "balance_after": to_float(after), + "allocation_count": len(allocations_data), + "allocation_balance_ids": [item[0].id for item in allocations_data[:20]], + "biz_key": biz_key, + "request_time": checked_at.isoformat(), + }, + ) + return CreditMutationResult( + user=user, + record=record, + created=True, + amount=to_float(consume_amount), + balance_before=to_float(before), + balance_after=to_float(after), + ) + + +async def _load_original_consume_allocations( + db: AsyncSession, + *, + original_record_id: str, +) -> list[_RefundAllocationSource]: + result = await db.execute( + select(CreditRecordAllocation) + .where( + CreditRecordAllocation.credit_record_id == original_record_id, + CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value, + ) + .order_by(CreditRecordAllocation.id.desc()) + ) + originals = list(result.scalars().all()) + if not originals: + return [] + original_ids = [item.id for item in originals] + transfer_result = await db.execute( + select(CreditRecordAllocation) + .where( + CreditRecordAllocation.source_allocation_id.in_(original_ids), + CreditRecordAllocation.allocation_action + == CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value, + ) + .order_by(CreditRecordAllocation.id.asc()) + ) + transfers_by_source: dict[str, list[CreditRecordAllocation]] = {} + for item in transfer_result.scalars().all(): + if item.source_allocation_id: + transfers_by_source.setdefault(item.source_allocation_id, []).append(item) + + sources: list[_RefundAllocationSource] = [] + for original in originals: + transferred = Decimal("0.00") + for transfer in transfers_by_source.get(original.id, []): + amount = to_credit_decimal(transfer.amount) + transferred += amount + sources.append( + _RefundAllocationSource( + original_allocation=original, + credit_balance_id=transfer.credit_balance_id, + amount=amount, + ) + ) + remaining = to_credit_decimal(original.amount) - transferred + if remaining < 0: + raise RuntimeError("升级积分来源迁移金额超过原消费分摊") + if remaining > 0: + sources.append( + _RefundAllocationSource( + original_allocation=original, + credit_balance_id=original.credit_balance_id, + amount=remaining, + ) + ) + return sources + + +async def refund_consumption( + db: AsyncSession, + *, + user_id: str, + refund_for_biz_key: str, + description: str, + related_id: str | None = None, + biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, + refund_time: datetime | None = None, +) -> CreditRefundResult: + checked_at = refund_time or utc_now() + await acquire_user_credit_lock(db, user_id) + user = await _load_user(db, user_id) + before = await get_available_credits(db, user_id, request_time=checked_at) + + existing_result = await db.execute( + select(CreditRecord).where( + CreditRecord.user_id == user_id, + CreditRecord.refund_for_biz_key == refund_for_biz_key, + CreditRecord.refund_kind.in_(["available", "expired"]), + ) + ) + existing_records = list(existing_result.scalars().all()) + if existing_records: + available = sum((to_credit_decimal(item.balance_delta) for item in existing_records), Decimal("0.00")) + expired = sum((to_credit_decimal(item.expired_amount) for item in existing_records), Decimal("0.00")) + attach_credit_snapshot(user, before) + return CreditRefundResult( + records=tuple(existing_records), + created=False, + total_amount=available + expired, + available_amount=available, + expired_amount=expired, + balance_before=before, + balance_after=before, + ) + + original_result = await db.execute( + select(CreditRecord) + .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == refund_for_biz_key) + .limit(1) + ) + original = original_result.scalar_one_or_none() + if original is None: + raise ValueError("未找到原积分消费流水") + + allocations = await _load_original_consume_allocations(db, original_record_id=original.id) + balance_ids = list(dict.fromkeys(item.credit_balance_id for item in allocations)) + balances_result = await db.execute( + select(UserCreditBalance) + .where(UserCreditBalance.id.in_(balance_ids), UserCreditBalance.user_id == user_id) + .order_by(UserCreditBalance.id.asc()) + .with_for_update() + ) + balance_map = {item.id: item for item in balances_result.scalars().all()} + + available_total = Decimal("0.00") + expired_total = Decimal("0.00") + available_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = [] + expired_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = [] + + for allocation_source in allocations: + allocation = allocation_source.original_allocation + balance = balance_map.get(allocation_source.credit_balance_id) + if balance is None: + raise RuntimeError(f"原积分来源不存在: {allocation_source.credit_balance_id}") + refund_amount = to_credit_decimal(allocation_source.amount) + before_unspent = to_credit_decimal(balance.unspent_amount) + before_consumed = to_credit_decimal(balance.consumed_amount) + if before_consumed < refund_amount: + raise RuntimeError("原积分批次已消费金额不足以退款") + after_consumed = before_consumed - refund_amount + balance.consumed_amount = after_consumed + if checked_at < balance.expires_at and balance.revoked_at is None: + after_unspent = before_unspent + refund_amount + balance.unspent_amount = after_unspent + balance.status = CreditBalanceStatus.ACTIVE.value + available_total += refund_amount + available_allocations.append( + (allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed) + ) + else: + before_expired = to_credit_decimal(balance.expired_amount) + balance.expired_amount = before_expired + refund_amount + after_unspent = before_unspent + if balance.unspent_amount == 0 and balance.consumed_amount == 0: + balance.status = CreditBalanceStatus.EXPIRED.value + expired_total += refund_amount + expired_allocations.append( + (allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed) + ) + + meta_kwargs = await _record_meta_kwargs(db, user, record_meta) + created_records: list[CreditRecord] = [] + balance_after = before + available_total + + async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord: + record = CreditRecord( + id=generate_id(), + user_id=user_id, + type=CreditRecordType.REFUND.value, + amount=amount, + balance_delta=(amount if kind == "available" else Decimal("0.00")), + expired_amount=expired_amount, + balance_after=balance_after, + description=( + f"{description},退回有效积分:{to_float(amount)}" + if kind == "available" + else f"{description},原积分已过期:{to_float(expired_amount)}" + ), + related_id=related_id, + request_time=checked_at, + biz_key=f"{biz_key or refund_for_biz_key + ':refund'}:{kind}", + refund_for_biz_key=refund_for_biz_key, + refund_kind=kind, + **meta_kwargs, + ) + db.add(record) + await db.flush() + return record + + if available_total > 0: + available_record = await create_refund_record("available", available_total, Decimal("0.00")) + created_records.append(available_record) + db.add_all( + [ + CreditRecordAllocation( + id=generate_id(), + credit_record_id=available_record.id, + credit_balance_id=balance.id, + user_id=user_id, + source_allocation_id=allocation.id, + allocation_action=CreditAllocationAction.REFUND_AVAILABLE.value, + amount=amount, + request_time=checked_at, + credit_level_snapshot=balance.credit_level, + source_type_snapshot=balance.source_type, + source_id_snapshot=balance.source_id, + valid_from_snapshot=balance.valid_from, + expires_at_snapshot=balance.expires_at, + unspent_before=before_unspent, + unspent_after=after_unspent, + consumed_before=before_consumed, + consumed_after=after_consumed, + ) + for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in available_allocations + ] + ) + if expired_total > 0: + expired_record = await create_refund_record("expired", expired_total, expired_total) + created_records.append(expired_record) + db.add_all( + [ + CreditRecordAllocation( + id=generate_id(), + credit_record_id=expired_record.id, + credit_balance_id=balance.id, + user_id=user_id, + source_allocation_id=allocation.id, + allocation_action=CreditAllocationAction.REFUND_EXPIRED.value, + amount=amount, + request_time=checked_at, + credit_level_snapshot=balance.credit_level, + source_type_snapshot=balance.source_type, + source_id_snapshot=balance.source_id, + valid_from_snapshot=balance.valid_from, + expires_at_snapshot=balance.expires_at, + unspent_before=before_unspent, + unspent_after=after_unspent, + consumed_before=before_consumed, + consumed_after=after_consumed, + ) + for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in expired_allocations + ] + ) + + attach_credit_snapshot(user, balance_after) + await db.flush() + log_operation_event( + domain="billing", + module="dynamic_credit", + event_type="CREDIT_SOURCE_REFUNDED", + event_status="success", + source="app.services.credit.ledger_service.refund_consumption", + user_id=user_id, + task_id=related_id, + message="按原积分来源完成业务失败退款", + detail={ + "refund_for_biz_key": refund_for_biz_key, + "available_refund": to_float(available_total), + "expired_refund": to_float(expired_total), + "source_count": len(allocations), + "created_record_ids": [item.id for item in created_records], + "refund_time": checked_at.isoformat(), + }, + ) + return CreditRefundResult( + records=tuple(created_records), + created=True, + total_amount=available_total + expired_total, + available_amount=available_total, + expired_amount=expired_total, + balance_before=before, + balance_after=balance_after, + ) + + +async def revoke_balances( + db: AsyncSession, + *, + balances: Iterable[UserCreditBalance], + description: str, + related_id: str | None, + biz_key: str, + request_time: datetime | None = None, +) -> CreditMutationResult: + items = list(balances) + if not items: + raise ValueError("没有可撤销的积分") + checked_at = request_time or utc_now() + user_id = items[0].user_id + await acquire_user_credit_lock(db, user_id) + user = await _load_user(db, user_id) + before = await get_available_credits(db, user_id, request_time=checked_at) + existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) + if existing: + attach_credit_snapshot(user, before) + return CreditMutationResult(user, existing, False, abs(to_float(existing.amount)), to_float(before), to_float(before)) + + total = Decimal("0.00") + allocation_rows: list[CreditRecordAllocation] = [] + record = CreditRecord( + id=generate_id(), + user_id=user_id, + type=CreditRecordType.REVOKE.value, + amount=Decimal("0.00"), + balance_delta=Decimal("0.00"), + expired_amount=Decimal("0.00"), + balance_after=before, + description=description, + related_id=related_id, + request_time=checked_at, + biz_key=biz_key, + billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value, + charge_action=CreditRecordAction.REFUND.value, + ) + db.add(record) + await db.flush() + + for balance in items: + if balance.user_id != user_id: + raise ValueError("不能跨用户撤销积分") + amount = to_credit_decimal(balance.unspent_amount) + if amount <= 0: + continue + before_unspent = amount + before_consumed = to_credit_decimal(balance.consumed_amount) + balance.unspent_amount = Decimal("0.00") + balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + amount + balance.revoked_at = checked_at + balance.status = CreditBalanceStatus.REVOKED.value + total += amount + allocation_rows.append( + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=balance.id, + user_id=user_id, + allocation_action=CreditAllocationAction.REVOKE.value, + amount=amount, + request_time=checked_at, + credit_level_snapshot=balance.credit_level, + source_type_snapshot=balance.source_type, + source_id_snapshot=balance.source_id, + valid_from_snapshot=balance.valid_from, + expires_at_snapshot=balance.expires_at, + unspent_before=before_unspent, + unspent_after=Decimal("0.00"), + consumed_before=before_consumed, + consumed_after=before_consumed, + ) + ) + after = before - total + record.amount = -total + record.balance_delta = -total + record.balance_after = after + db.add_all(allocation_rows) + attach_credit_snapshot(user, after) + await db.flush() + log_operation_event( + domain="billing", + module="dynamic_credit", + event_type="CREDIT_REVOKED", + event_status="success", + source="app.services.credit.ledger_service.revoke_balances", + user_id=user_id, + task_id=related_id, + message="积分批次撤销完成", + detail={ + "record_id": record.id, + "amount": to_float(total), + "balance_count": len(allocation_rows), + "biz_key": biz_key, + }, + ) + return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after)) diff --git a/video-gen-api/app/services/credit/locking.py b/video-gen-api/app/services/credit/locking.py new file mode 100644 index 00000000..f4bc13e0 --- /dev/null +++ b/video-gen-api/app/services/credit/locking.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None: + """PostgreSQL 事务级用户锁;SQLite 调试环境无需额外锁。""" + bind = db.get_bind() + dialect_name = bind.dialect.name if bind is not None else "" + if dialect_name == "postgresql": + await db.execute( + text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"), + {"lock_key": f"credit:{user_id}"}, + ) diff --git a/video-gen-api/app/services/credit/product_service.py b/video-gen-api/app/services/credit/product_service.py new file mode 100644 index 00000000..817f95f5 --- /dev/null +++ b/video-gen-api/app/services/credit/product_service.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_product import ( + CreditProductType, + ProductPriceType, + SUBSCRIPTION_GRANT_COUNT, + SubscriptionBillingCycle, +) +from app.enums.credit_subscription import ( + CreditSubscriptionPeriodStatus, + CreditSubscriptionStatus, +) +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.user import User +from app.services.credit.utils import to_credit_decimal, utc_now + + +@dataclass(slots=True, frozen=True) +class ProductPriceQuote: + product: CreditProduct + purchase_scene: str + price_type: str + base_price: Decimal + activity_price: Decimal | None + target_price: Decimal + deduction_amount: Decimal + payable_amount: Decimal + source_subscription_id: str | None = None + upgrade_period_ids: tuple[str, ...] = () + + +def grant_count_for_cycle(cycle: str | None) -> int: + try: + return SUBSCRIPTION_GRANT_COUNT[str(cycle)] + except KeyError as exc: + raise ValueError("不支持的订阅周期") from exc + + +def activity_price_if_valid(product: CreditProduct, request_time: datetime) -> Decimal | None: + if product.activity_price is None: + return None + if product.activity_start_at is None or product.activity_end_at is None: + return None + if not (product.activity_start_at <= request_time < product.activity_end_at): + return None + return to_credit_decimal(product.activity_price) + + +def current_product_price( + product: CreditProduct, + *, + first_purchase: bool, + request_time: datetime, + upgrade: bool = False, +) -> tuple[Decimal, Decimal | None, Decimal, str]: + if product.product_type == CreditProductType.CREDIT_ADDON.value: + price = to_credit_decimal(product.price) + return price, None, price, ProductPriceType.REGULAR.value + base = to_credit_decimal( + product.regular_price if upgrade or not first_purchase else product.first_purchase_price + ) + activity = activity_price_if_valid(product, request_time) + if activity is not None and activity < base: + return base, activity, activity, ProductPriceType.ACTIVITY.value + return base, activity, base, ( + ProductPriceType.UPGRADE.value + if upgrade + else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value + ) + + +async def get_active_subscription( + db: AsyncSession, + user_id: str, + *, + request_time: datetime | None = None, + for_update: bool = False, +) -> UserCreditSubscription | None: + checked_at = request_time or utc_now() + stmt = ( + select(UserCreditSubscription) + .where( + UserCreditSubscription.user_id == user_id, + UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value, + UserCreditSubscription.start_at <= checked_at, + UserCreditSubscription.expires_at > checked_at, + ) + .order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc()) + .limit(1) + ) + if for_update: + stmt = stmt.with_for_update() + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def get_upgrade_deduction_preview( + db: AsyncSession, + *, + subscription: UserCreditSubscription, + request_time: datetime, +) -> Decimal: + if subscription.billing_cycle not in { + SubscriptionBillingCycle.QUARTERLY.value, + SubscriptionBillingCycle.YEARLY.value, + }: + return Decimal("0.00") + result = await db.execute( + select(UserCreditSubscriptionPeriod.allocated_paid_amount).where( + UserCreditSubscriptionPeriod.subscription_id == subscription.id, + UserCreditSubscriptionPeriod.scheduled_at > request_time, + UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value, + ) + ) + return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00")) + + +async def list_active_products(db: AsyncSession) -> list[CreditProduct]: + result = await db.execute( + select(CreditProduct) + .where(CreditProduct.is_active.is_(True)) + .order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc()) + ) + return list(result.scalars().all()) + + +async def get_product(db: AsyncSession, product_id: str, *, for_update: bool = False) -> CreditProduct | None: + stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1) + if for_update: + stmt = stmt.with_for_update() + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +async def build_product_catalog( + db: AsyncSession, + *, + user: User, + request_time: datetime | None = None, +) -> dict: + checked_at = request_time or utc_now() + products = await list_active_products(db) + current = await get_active_subscription(db, user.id, request_time=checked_at) + first_purchase = user.first_membership_paid_at is None + subscription_products: list[dict] = [] + credit_addons: list[dict] = [] + upgrade_deduction = ( + await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at) + if current is not None + else Decimal("0.00") + ) + + for product in products: + if product.product_type == CreditProductType.CREDIT_ADDON.value: + credit_addons.append(product_to_dict(product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True)) + continue + # 首订资格已经使用后,未开启续费的套餐不返回给客户端。 + # 该过滤同时适用于过期后的续费和有效订阅期间的升级入口, + # 避免仅靠客户端隐藏后仍可被直接构造请求购买。 + if not first_purchase and not bool(product.renewal_enabled): + continue + + can_purchase = current is None + can_upgrade = False + reason = None + if current is not None: + can_upgrade = ( + product.billing_cycle == current.billing_cycle + and int(product.tier_rank or 0) > int(current.tier_rank or 0) + ) + can_purchase = can_upgrade + if not can_upgrade: + reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐" + _, _, target_price, price_type = current_product_price( + product, + first_purchase=first_purchase, + request_time=checked_at, + upgrade=can_upgrade, + ) + deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00") + user_price = max(Decimal("0.00"), target_price - deduction_amount) + if can_upgrade and user_price <= Decimal("0.00"): + can_purchase = False + reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理" + item = product_to_dict( + product, + user_price=user_price, + price_type=price_type, + can_purchase=can_purchase, + target_price=target_price, + deduction_amount=deduction_amount, + ) + item["can_upgrade"] = can_upgrade + item["unavailable_reason"] = reason + subscription_products.append(item) + + return { + "subscription_products": subscription_products, + "credit_addons": credit_addons, + "first_purchase_available": first_purchase, + "current_subscription": subscription_to_dict(current) if current else None, + } + + +def product_to_dict( + product: CreditProduct, + *, + user_price: Decimal | None = None, + price_type: str | None = None, + can_purchase: bool | None = None, + target_price: Decimal | None = None, + deduction_amount: Decimal | None = None, +) -> dict: + return { + "id": product.id, + "product_code": product.product_code, + "product_type": product.product_type, + "name": product.name, + "description": product.description, + "features": product.features_json or [], + "tier_code": product.tier_code, + "tier_rank": product.tier_rank, + "billing_cycle": product.billing_cycle, + "monthly_grant_credits": float(product.monthly_grant_credits or 0), + "grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1, + "first_purchase_price": float(product.first_purchase_price or 0), + "regular_price": float(product.regular_price or 0), + "activity_price": float(product.activity_price) if product.activity_price is not None else None, + "activity_start_at": product.activity_start_at, + "activity_end_at": product.activity_end_at, + "renewal_enabled": bool(product.renewal_enabled), + "grant_credits": float(product.grant_credits or 0), + "validity_months": 1 if product.is_credit_addon else None, + "price": float(user_price if user_price is not None else product.price), + "current_price": float(user_price if user_price is not None else product.price), + "target_price": float(target_price) if target_price is not None else None, + "deduction_amount": float(deduction_amount or Decimal("0.00")), + "price_type": price_type, + "credit_level": product.credit_level, + "currency": product.currency, + "is_active": product.is_active, + "sort_order": product.sort_order, + "can_purchase": can_purchase, + } + + +def subscription_to_dict(subscription: UserCreditSubscription) -> dict: + return { + "id": subscription.id, + "product_id": subscription.product_id, + "status": subscription.status, + "purchase_scene": subscription.purchase_scene, + "tier_code": subscription.tier_code, + "tier_rank": subscription.tier_rank, + "billing_cycle": subscription.billing_cycle, + "anchor_at": subscription.anchor_at, + "start_at": subscription.start_at, + "expires_at": subscription.expires_at, + "monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot), + "grant_count": subscription.grant_count, + "granted_count": subscription.granted_count, + } diff --git a/video-gen-api/app/services/credit/query_service.py b/video-gen-api/app/services/credit/query_service.py new file mode 100644 index 00000000..ee202bcd --- /dev/null +++ b/video-gen-api/app/services/credit/query_service.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from typing import Iterable + +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_balance import CreditBalanceStatus +from app.models.credit.balance import UserCreditBalance +from app.services.credit.time_policy import last_usable_at +from app.services.credit.utils import to_credit_decimal, to_float, utc_now + + +@dataclass(slots=True, frozen=True) +class CreditBalanceSummary: + available_credits: Decimal + next_expiring_credits: Decimal + next_expires_at: datetime | None + next_last_usable_at: datetime | None + + def to_dict(self) -> dict: + return { + "available_credits": to_float(self.available_credits), + "credits": to_float(self.available_credits), + "next_expiring_credits": to_float(self.next_expiring_credits), + "next_expires_at": self.next_expires_at, + "next_last_usable_at": self.next_last_usable_at, + } + + +async def get_available_credits( + db: AsyncSession, + user_id: str, + *, + request_time: datetime | None = None, +) -> Decimal: + checked_at = request_time or utc_now() + result = await db.execute( + select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where( + UserCreditBalance.user_id == user_id, + UserCreditBalance.valid_from <= checked_at, + UserCreditBalance.expires_at > checked_at, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.revoked_at.is_(None), + ) + ) + return to_credit_decimal(result.scalar_one()) + + +async def get_balance_summary( + db: AsyncSession, + user_id: str, + *, + request_time: datetime | None = None, +) -> CreditBalanceSummary: + checked_at = request_time or utc_now() + available = await get_available_credits(db, user_id, request_time=checked_at) + expiry_result = await db.execute( + select( + UserCreditBalance.expires_at, + func.sum(UserCreditBalance.unspent_amount).label("amount"), + ) + .where( + UserCreditBalance.user_id == user_id, + UserCreditBalance.valid_from <= checked_at, + UserCreditBalance.expires_at > checked_at, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.revoked_at.is_(None), + ) + .group_by(UserCreditBalance.expires_at) + .order_by(UserCreditBalance.expires_at.asc()) + .limit(1) + ) + row = expiry_result.first() + expires_at = row.expires_at if row else None + expiring = to_credit_decimal(row.amount if row else 0) + return CreditBalanceSummary( + available_credits=available, + next_expiring_credits=expiring, + next_expires_at=expires_at, + next_last_usable_at=last_usable_at(expires_at) if expires_at else None, + ) + + +async def get_user_credit_map( + db: AsyncSession, + user_ids: Iterable[str], + *, + request_time: datetime | None = None, +) -> dict[str, float]: + ids = list(dict.fromkeys(str(item) for item in user_ids if item)) + if not ids: + return {} + checked_at = request_time or utc_now() + result = await db.execute( + select( + UserCreditBalance.user_id, + func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"), + ) + .where( + UserCreditBalance.user_id.in_(ids), + UserCreditBalance.valid_from <= checked_at, + UserCreditBalance.expires_at > checked_at, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.revoked_at.is_(None), + ) + .group_by(UserCreditBalance.user_id) + ) + output = {user_id: 0.0 for user_id in ids} + for row in result: + output[str(row.user_id)] = to_float(row.credits) + return output + + +def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object: + # SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。 + setattr(user, "credits", to_float(to_credit_decimal(credits))) + return user + + +def effective_balance_status(balance: UserCreditBalance, *, request_time: datetime | None = None) -> str: + checked_at = request_time or utc_now() + if balance.revoked_at is not None or to_credit_decimal(balance.revoked_amount) > 0: + return CreditBalanceStatus.REVOKED.value + if balance.expires_at <= checked_at: + return CreditBalanceStatus.EXPIRED.value + if balance.valid_from > checked_at: + return CreditBalanceStatus.SCHEDULED.value + if to_credit_decimal(balance.unspent_amount) <= 0: + return CreditBalanceStatus.CONSUMED.value + return CreditBalanceStatus.ACTIVE.value + + +def apply_balance_status_filter(stmt, status: str | None, *, request_time: datetime): + if not status: + return stmt + if status == CreditBalanceStatus.REVOKED.value: + return stmt.where( + or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0) + ) + base_not_revoked = and_( + UserCreditBalance.revoked_at.is_(None), + UserCreditBalance.revoked_amount <= 0, + ) + if status == CreditBalanceStatus.EXPIRED.value: + return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time) + if status == CreditBalanceStatus.SCHEDULED.value: + return stmt.where( + base_not_revoked, + UserCreditBalance.valid_from > request_time, + UserCreditBalance.expires_at > request_time, + ) + if status == CreditBalanceStatus.CONSUMED.value: + return stmt.where( + base_not_revoked, + UserCreditBalance.valid_from <= request_time, + UserCreditBalance.expires_at > request_time, + UserCreditBalance.unspent_amount <= 0, + ) + if status == CreditBalanceStatus.ACTIVE.value: + return stmt.where( + base_not_revoked, + UserCreditBalance.valid_from <= request_time, + UserCreditBalance.expires_at > request_time, + UserCreditBalance.unspent_amount > 0, + ) + return stmt.where(UserCreditBalance.status == status) diff --git a/video-gen-api/app/services/credit/subscription_service.py b/video-gen-api/app/services/credit/subscription_service.py new file mode 100644 index 00000000..2fdd3e84 --- /dev/null +++ b/video-gen-api/app/services/credit/subscription_service.py @@ -0,0 +1,808 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_balance import ( + CreditAllocationAction, + CreditBalanceSourceType, + CreditBalanceStatus, +) +from app.enums.credit_product import CreditProductType +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType +from app.enums.credit_subscription import ( + CreditSubscriptionPeriodStatus, + CreditSubscriptionStatus, +) +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_record import CreditRecord +from app.models.payment_order import PaymentOrder +from app.models.user import User +from app.services.credit.ledger_service import grant_credits +from app.services.credit.locking import acquire_user_credit_lock +from app.services.credit.product_service import grant_count_for_cycle +from app.services.credit.query_service import get_available_credits +from app.services.credit.time_policy import add_natural_months, natural_month_period +from app.services.credit.utils import to_credit_decimal, utc_now +from app.services.operation_log_service import log_operation_event +from app.utils.id_gen import generate_id + + +def _allocate_amount_by_period(total: Decimal, count: int) -> list[Decimal]: + cents = int((to_credit_decimal(total) * 100).to_integral_value()) + base, remainder = divmod(cents, count) + values = [Decimal(base) / 100 for _ in range(count)] + values[-1] += Decimal(remainder) / 100 + return [to_credit_decimal(item) for item in values] + + +def _product_snapshot(product: CreditProduct) -> dict: + return { + "id": product.id, + "product_code": product.product_code, + "product_type": product.product_type, + "name": product.name, + "tier_code": product.tier_code, + "tier_rank": product.tier_rank, + "billing_cycle": product.billing_cycle, + "monthly_grant_credits": float(product.monthly_grant_credits or 0), + "first_purchase_price": float(product.first_purchase_price or 0), + "regular_price": float(product.regular_price or 0), + "activity_price": float(product.activity_price) if product.activity_price is not None else None, + "grant_credits": float(product.grant_credits or 0), + "credit_level": product.credit_level, + "features": product.features_json or [], + } + + +def _resolve_order_product_snapshot( + order: PaymentOrder, + product: CreditProduct | None, +) -> dict: + """返回支付创建时冻结的商品快照。 + + 支付履约不得读取后台后来修改后的价格、积分或套餐周期。旧订单若尚未 + 保存快照,才允许使用当前商品生成一次兼容快照。 + """ + snapshot = dict(order.product_snapshot_json or {}) + if not snapshot and product is not None: + snapshot = _product_snapshot(product) + if not snapshot: + raise ValueError("订单缺少商品快照,无法履约") + return snapshot + + +def _snapshot_decimal(snapshot: dict, key: str) -> Decimal: + return to_credit_decimal(snapshot.get(key) or 0) + + +async def _create_subscription( + db: AsyncSession, + *, + order: PaymentOrder, + product: CreditProduct | None, + paid_at: datetime, +) -> tuple[UserCreditSubscription, list[UserCreditSubscriptionPeriod]]: + snapshot = _resolve_order_product_snapshot(order, product) + billing_cycle = str(snapshot.get("billing_cycle") or "") + count = grant_count_for_cycle(billing_cycle) + expires_at = add_natural_months(paid_at, count) + # 升级订单的现金实付额已扣除旧套餐未来周期价值;新订阅后续再升级时, + # 周期价值必须按目标套餐完整价格快照分摊,不能只按本次现金实付额分摊。 + pricing_basis_amount = to_credit_decimal( + order.target_price_snapshot + if order.purchase_scene == "upgrade" and order.target_price_snapshot is not None + else order.amount + ) + subscription = UserCreditSubscription( + id=generate_id(), + user_id=order.user_id, + product_id=order.product_id, + payment_order_id=order.id, + status=CreditSubscriptionStatus.ACTIVE.value, + purchase_scene=str(order.purchase_scene or "renewal"), + tier_code=str(snapshot.get("tier_code") or ""), + tier_rank=int(snapshot.get("tier_rank") or 0), + billing_cycle=billing_cycle, + anchor_at=paid_at, + start_at=paid_at, + expires_at=expires_at, + next_grant_at=(add_natural_months(paid_at, 1) if count > 1 else None), + monthly_grant_credits_snapshot=_snapshot_decimal(snapshot, "monthly_grant_credits"), + grant_count=count, + granted_count=0, + paid_amount_snapshot=pricing_basis_amount, + product_snapshot_json=snapshot, + source_subscription_id=order.source_subscription_id, + upgrade_order_id=(order.id if order.purchase_scene == "upgrade" else None), + ) + db.add(subscription) + await db.flush() + + allocations = _allocate_amount_by_period(pricing_basis_amount, count) + periods: list[UserCreditSubscriptionPeriod] = [] + for sequence in range(count): + start, end = natural_month_period(paid_at, sequence) + period = UserCreditSubscriptionPeriod( + id=generate_id(), + subscription_id=subscription.id, + sequence=sequence + 1, + scheduled_at=start, + valid_from=start, + expires_at=end, + grant_credits=_snapshot_decimal(snapshot, "monthly_grant_credits"), + allocated_paid_amount=allocations[sequence], + status=CreditSubscriptionPeriodStatus.SCHEDULED.value, + ) + db.add(period) + periods.append(period) + await db.flush() + return subscription, periods + + +async def grant_subscription_period( + db: AsyncSession, + *, + subscription: UserCreditSubscription, + period: UserCreditSubscriptionPeriod, + request_time: datetime | None = None, +) -> UserCreditBalance: + checked_at = request_time or utc_now() + if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and period.issued_balance_id: + result = await db.execute( + select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1) + ) + existing = result.scalar_one_or_none() + if existing: + return existing + if period.status not in { + CreditSubscriptionPeriodStatus.SCHEDULED.value, + CreditSubscriptionPeriodStatus.GRANTED.value, + }: + raise ValueError("当前订阅周期不能发放积分") + mutation = await grant_credits( + db, + user_id=subscription.user_id, + amount=period.grant_credits, + description=f"订阅套餐第{period.sequence}个月积分发放", + source_type=CreditBalanceSourceType.SUBSCRIPTION_GRANT.value, + valid_from=period.valid_from, + expires_at=period.expires_at, + credit_level=str(subscription.product_snapshot_json.get("credit_level") or "general"), + source_id=period.id, + product_id=subscription.product_id, + payment_order_id=subscription.payment_order_id, + subscription_id=subscription.id, + subscription_period_id=period.id, + related_id=subscription.id, + record_type=CreditRecordType.RECHARGE.value, + biz_key=f"subscription:{subscription.id}:period:{period.sequence}:grant", + metadata_json={"period_sequence": period.sequence}, + request_time=checked_at, + ) + result = await db.execute( + select(UserCreditBalance) + .where(UserCreditBalance.grant_record_id == mutation.record.id) + .limit(1) + ) + balance = result.scalar_one() + period.status = CreditSubscriptionPeriodStatus.GRANTED.value + period.issued_balance_id = balance.id + period.issued_at = checked_at + subscription.granted_count = max(subscription.granted_count, period.sequence) + subscription.next_grant_at = ( + add_natural_months(subscription.anchor_at, period.sequence) + if period.sequence < subscription.grant_count + else None + ) + await db.flush() + return balance + + +async def _reconcile_upgrade_period_mismatch( + db: AsyncSession, + *, + order: PaymentOrder, + new_first_balance: UserCreditBalance, + checked_at: datetime, +) -> None: + """核对升级订单创建时的抵扣周期与支付结算时状态。 + + 正常情况下抵扣周期一直处于 upgrade_reserved,仅取消未来发放。 + 若历史竞态导致周期已发放,则撤销未消费积分,并把每一笔尚未退款的 + 消费分摊通过 source_allocation_id 迁移到升级套餐首期积分,保证后续 + 业务失败退款仍能准确退回当前实际承担成本的积分来源。 + """ + period_ids = list(order.upgrade_period_ids_json or []) + if not period_ids: + return + result = await db.execute( + select(UserCreditSubscriptionPeriod) + .where(UserCreditSubscriptionPeriod.id.in_(period_ids)) + .order_by(UserCreditSubscriptionPeriod.id.asc()) + .with_for_update() + ) + periods = list(result.scalars().all()) + expected_ids = [str(item) for item in period_ids] + if len(expected_ids) != len(set(expected_ids)): + raise RuntimeError("升级订单抵扣周期快照存在重复ID") + actual_ids = {str(period.id) for period in periods} + if actual_ids != set(expected_ids): + raise RuntimeError("升级订单抵扣周期快照与实际周期集合不一致") + if not order.source_subscription_id: + raise RuntimeError("升级订单缺少原订阅ID") + allowed_statuses = { + CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value, + CreditSubscriptionPeriodStatus.GRANTED.value, + } + for period in periods: + if str(period.subscription_id) != str(order.source_subscription_id): + raise RuntimeError("升级订单抵扣周期不属于原订阅") + if str(period.upgrade_order_id or "") != str(order.id): + raise RuntimeError("升级订单抵扣周期未绑定当前订单") + if period.status not in allowed_statuses: + raise RuntimeError(f"升级订单抵扣周期状态不允许结算:{period.status}") + if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and not period.issued_balance_id: + raise RuntimeError("升级订单抵扣周期已发放但缺少积分来源记录") + if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value and period.issued_balance_id: + raise RuntimeError("升级订单抵扣周期仍为预留状态但已存在积分来源记录") + + reserved = [ + period for period in periods + if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value + ] + inconsistent = [ + period + for period in periods + if period.status == CreditSubscriptionPeriodStatus.GRANTED.value + and period.issued_balance_id + ] + for period in reserved: + period.status = CreditSubscriptionPeriodStatus.CANCELLED_BY_UPGRADE.value + period.cancelled_at = checked_at + if not inconsistent: + return + + balance_ids = [str(period.issued_balance_id) for period in inconsistent if period.issued_balance_id] + balance_result = await db.execute( + select(UserCreditBalance) + .where(UserCreditBalance.id.in_(balance_ids)) + .order_by(UserCreditBalance.id.asc()) + .with_for_update() + ) + old_balances = list(balance_result.scalars().all()) + if {str(item.id) for item in old_balances} != set(balance_ids): + raise RuntimeError("升级边界补偿缺少原积分批次") + period_by_balance = {str(period.issued_balance_id): period for period in inconsistent} + for balance in old_balances: + period = period_by_balance.get(str(balance.id)) + if period is None: + raise RuntimeError("升级边界补偿积分批次与周期无法对应") + if str(balance.user_id) != str(order.user_id): + raise RuntimeError("升级边界补偿积分批次不属于当前用户") + if str(balance.subscription_id or "") != str(order.source_subscription_id): + raise RuntimeError("升级边界补偿积分批次不属于原订阅") + if str(balance.subscription_period_id or "") != str(period.id): + raise RuntimeError("升级边界补偿积分批次与订阅周期不一致") + total_transfer = sum( + (to_credit_decimal(item.consumed_amount) for item in old_balances), + Decimal("0.00"), + ) + if to_credit_decimal(new_first_balance.unspent_amount) < total_transfer: + raise RuntimeError("升级套餐首期积分不足以承接边界消费来源迁移") + + before_available = await get_available_credits(db, order.user_id, request_time=checked_at) + record = CreditRecord( + id=generate_id(), + user_id=order.user_id, + type=CreditRecordType.REVOKE.value, + amount=Decimal("0.00"), + balance_delta=Decimal("0.00"), + expired_amount=Decimal("0.00"), + balance_after=before_available, + description="套餐升级边界核对:废弃重复发放积分并迁移已消费来源", + related_id=order.id, + request_time=checked_at, + biz_key=f"payment-order:{order.id}:upgrade-reconcile", + billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value, + ) + db.add(record) + await db.flush() + + new_unspent_cursor = to_credit_decimal(new_first_balance.unspent_amount) + new_consumed_cursor = to_credit_decimal(new_first_balance.consumed_amount) + total_removed = Decimal("0.00") + + for old in old_balances: + old_unspent = to_credit_decimal(old.unspent_amount) + old_consumed = to_credit_decimal(old.consumed_amount) + total_removed += old_unspent + old_consumed + + if old_unspent > 0: + db.add( + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=old.id, + user_id=order.user_id, + allocation_action=CreditAllocationAction.REVOKE.value, + amount=old_unspent, + request_time=checked_at, + credit_level_snapshot=old.credit_level, + source_type_snapshot=old.source_type, + source_id_snapshot=old.source_id, + valid_from_snapshot=old.valid_from, + expires_at_snapshot=old.expires_at, + unspent_before=old_unspent, + unspent_after=Decimal("0.00"), + consumed_before=old_consumed, + consumed_after=old_consumed, + ) + ) + + if old_consumed > 0: + consume_result = await db.execute( + select(CreditRecordAllocation) + .where( + CreditRecordAllocation.credit_balance_id == old.id, + CreditRecordAllocation.allocation_action + == CreditAllocationAction.CONSUME.value, + ) + .order_by(CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc()) + ) + original_allocations = list(consume_result.scalars().all()) + original_ids = [item.id for item in original_allocations] + adjusted_by_source: dict[str, Decimal] = {} + if original_ids: + adjusted_result = await db.execute( + select(CreditRecordAllocation).where( + CreditRecordAllocation.source_allocation_id.in_(original_ids), + CreditRecordAllocation.allocation_action.in_( + [ + CreditAllocationAction.REFUND_AVAILABLE.value, + CreditAllocationAction.REFUND_EXPIRED.value, + CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value, + ] + ), + ) + ) + for adjusted in adjusted_result.scalars().all(): + if adjusted.source_allocation_id: + adjusted_by_source[adjusted.source_allocation_id] = ( + adjusted_by_source.get(adjusted.source_allocation_id, Decimal("0.00")) + + to_credit_decimal(adjusted.amount) + ) + remaining = old_consumed + old_consumed_cursor = old_consumed + for original_allocation in original_allocations: + if remaining <= 0: + break + original_amount = to_credit_decimal(original_allocation.amount) + already_adjusted = adjusted_by_source.get(original_allocation.id, Decimal("0.00")) + available_to_transfer = original_amount - already_adjusted + if available_to_transfer < 0: + raise RuntimeError("消费分摊的退款或迁移金额超过原消费金额") + transfer_amount = min(available_to_transfer, remaining) + if transfer_amount <= 0: + continue + db.add( + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=old.id, + user_id=order.user_id, + source_allocation_id=original_allocation.id, + allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value, + amount=transfer_amount, + request_time=checked_at, + credit_level_snapshot=old.credit_level, + source_type_snapshot=old.source_type, + source_id_snapshot=old.source_id, + valid_from_snapshot=old.valid_from, + expires_at_snapshot=old.expires_at, + unspent_before=Decimal("0.00"), + unspent_after=Decimal("0.00"), + consumed_before=old_consumed_cursor, + consumed_after=old_consumed_cursor - transfer_amount, + ) + ) + db.add( + CreditRecordAllocation( + id=generate_id(), + credit_record_id=record.id, + credit_balance_id=new_first_balance.id, + user_id=order.user_id, + source_allocation_id=original_allocation.id, + allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value, + amount=transfer_amount, + request_time=checked_at, + credit_level_snapshot=new_first_balance.credit_level, + source_type_snapshot=new_first_balance.source_type, + source_id_snapshot=new_first_balance.source_id, + valid_from_snapshot=new_first_balance.valid_from, + expires_at_snapshot=new_first_balance.expires_at, + unspent_before=new_unspent_cursor, + unspent_after=new_unspent_cursor - transfer_amount, + consumed_before=new_consumed_cursor, + consumed_after=new_consumed_cursor + transfer_amount, + ) + ) + old_consumed_cursor -= transfer_amount + new_unspent_cursor -= transfer_amount + new_consumed_cursor += transfer_amount + remaining -= transfer_amount + if remaining > 0: + raise RuntimeError("无法定位全部已消费积分的原始业务分摊,升级结算已中止") + + old.unspent_amount = Decimal("0.00") + old.consumed_amount = Decimal("0.00") + old.revoked_amount = to_credit_decimal(old.revoked_amount) + old_unspent + old_consumed + old.revoked_at = checked_at + old.status = CreditBalanceStatus.REVOKED.value + for period in inconsistent: + if period.issued_balance_id == old.id: + period.status = CreditSubscriptionPeriodStatus.REVOKED_BY_UPGRADE.value + period.revoked_at = checked_at + + new_first_balance.unspent_amount = new_unspent_cursor + new_first_balance.consumed_amount = new_consumed_cursor + if new_unspent_cursor == 0: + new_first_balance.status = CreditBalanceStatus.CONSUMED.value + + record.amount = -total_removed + record.balance_delta = -total_removed + record.balance_after = before_available - total_removed + await db.flush() + + +async def fulfill_payment_product( + db: AsyncSession, + *, + order: PaymentOrder, + fulfilled_at: datetime | None = None, +) -> None: + if order.fulfillment_status == "fulfilled": + return + checked_at = fulfilled_at or order.paid_at or utc_now() + await acquire_user_credit_lock(db, order.user_id) + user_result = await db.execute(select(User).where(User.id == order.user_id).with_for_update().limit(1)) + user = user_result.scalar_one() + product_result = await db.execute( + select(CreditProduct).where(CreditProduct.id == order.product_id).limit(1) + ) + product = product_result.scalar_one_or_none() + snapshot = _resolve_order_product_snapshot(order, product) + product_type = str(order.product_type or snapshot.get("product_type") or "") + + if product_type == CreditProductType.CREDIT_ADDON.value: + await grant_credits( + db, + user_id=order.user_id, + amount=_snapshot_decimal(snapshot, "grant_credits"), + description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}", + source_type=CreditBalanceSourceType.CREDIT_ADDON.value, + valid_from=checked_at, + expires_at=add_natural_months(checked_at, 1), + credit_level=str(snapshot.get("credit_level") or "general"), + source_id=order.id, + product_id=order.product_id, + payment_order_id=order.id, + related_id=order.id, + biz_key=f"payment-order:{order.id}:credit-addon:grant", + request_time=checked_at, + ) + order.fulfillment_status = "fulfilled" + order.fulfilled_at = checked_at + return + + old_subscription: UserCreditSubscription | None = None + if order.purchase_scene == "upgrade": + if not order.source_subscription_id: + order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value + await db.flush() + return + old_result = await db.execute( + select(UserCreditSubscription) + .where(UserCreditSubscription.id == order.source_subscription_id) + .with_for_update() + .limit(1) + ) + old_subscription = old_result.scalar_one_or_none() + if old_subscription is None or str(old_subscription.user_id) != str(order.user_id): + order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value + await db.flush() + return + + if order.purchase_scene != "upgrade": + # 首充/续费维持原有支付履约语义:创建或发放异常继续向外抛出, + # 不能被升级专用的核对失败状态吞掉。 + subscription, periods = await _create_subscription( + db, + order=order, + product=product, + paid_at=checked_at, + ) + await grant_subscription_period( + db, + subscription=subscription, + period=periods[0], + request_time=checked_at, + ) + else: + try: + async with db.begin_nested(): + subscription, periods = await _create_subscription( + db, + order=order, + product=product, + paid_at=checked_at, + ) + first_balance = await grant_subscription_period( + db, + subscription=subscription, + period=periods[0], + request_time=checked_at, + ) + await _reconcile_upgrade_period_mismatch( + db, + order=order, + new_first_balance=first_balance, + checked_at=checked_at, + ) + except Exception as exc: + order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value + await db.flush() + log_operation_event( + domain="billing", + module="credit_upgrade", + event_type="CREDIT_UPGRADE_RECONCILE_FAILED", + event_status="failed", + source="app.services.credit.subscription_service.fulfill_payment_product", + user_id=str(order.user_id), + task_id=str(order.id), + message="套餐升级支付履约周期核对失败", + error=str(exc), + detail={ + "order_id": str(order.id), + "order_no": str(order.order_no), + "source_subscription_id": str(order.source_subscription_id or ""), + "upgrade_period_ids": list(order.upgrade_period_ids_json or []), + }, + ) + return + + if old_subscription is not None: + old_subscription.status = CreditSubscriptionStatus.UPGRADED.value + old_subscription.upgrade_order_id = order.id + order.subscription_id = subscription.id + order.fulfillment_status = "fulfilled" + order.fulfilled_at = checked_at + if user.first_membership_paid_at is None: + user.first_membership_paid_at = checked_at + await db.flush() + + +async def list_due_subscription_period_candidates( + db: AsyncSession, + *, + request_time: datetime, + batch_size: int = 100, +) -> list[tuple[str, str, str]]: + result = await db.execute( + select( + UserCreditSubscriptionPeriod.id, + UserCreditSubscriptionPeriod.subscription_id, + UserCreditSubscription.user_id, + ) + .join( + UserCreditSubscription, + UserCreditSubscription.id == UserCreditSubscriptionPeriod.subscription_id, + ) + .where( + UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value, + UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value, + UserCreditSubscriptionPeriod.scheduled_at <= request_time, + ) + .order_by(UserCreditSubscriptionPeriod.scheduled_at.asc(), UserCreditSubscriptionPeriod.id.asc()) + .limit(max(1, batch_size)) + ) + return [(str(row.id), str(row.subscription_id), str(row.user_id)) for row in result.all()] + + +async def grant_due_subscription_period_by_id( + db: AsyncSession, + *, + period_id: str, + subscription_id: str, + user_id: str, + request_time: datetime, +) -> bool: + await acquire_user_credit_lock(db, user_id) + subscription_result = await db.execute( + select(UserCreditSubscription) + .where(UserCreditSubscription.id == subscription_id) + .with_for_update() + .limit(1) + ) + subscription = subscription_result.scalar_one_or_none() + if subscription is None or subscription.status != CreditSubscriptionStatus.ACTIVE.value: + return False + period_result = await db.execute( + select(UserCreditSubscriptionPeriod) + .where(UserCreditSubscriptionPeriod.id == period_id) + .with_for_update() + .limit(1) + ) + period = period_result.scalar_one_or_none() + if ( + period is None + or period.subscription_id != subscription.id + or period.status != CreditSubscriptionPeriodStatus.SCHEDULED.value + or period.scheduled_at > request_time + ): + return False + await grant_subscription_period( + db, + subscription=subscription, + period=period, + request_time=request_time, + ) + return True + + +async def grant_due_subscription_periods( + db: AsyncSession, + *, + request_time: datetime | None = None, + batch_size: int = 100, +) -> int: + checked_at = request_time or utc_now() + candidates = await list_due_subscription_period_candidates( + db, request_time=checked_at, batch_size=batch_size + ) + count = 0 + for period_id, subscription_id, user_id in candidates: + if await grant_due_subscription_period_by_id( + db, + period_id=period_id, + subscription_id=subscription_id, + user_id=user_id, + request_time=checked_at, + ): + count += 1 + return count + + +async def list_due_subscription_ids( + db: AsyncSession, + *, + request_time: datetime, + batch_size: int = 500, +) -> list[str]: + result = await db.execute( + select(UserCreditSubscription.id) + .where( + UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value, + UserCreditSubscription.expires_at <= request_time, + ) + .order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc()) + .limit(max(1, batch_size)) + ) + return [str(item) for item in result.scalars().all()] + + +async def expire_subscription_by_id( + db: AsyncSession, + *, + subscription_id: str, + request_time: datetime, +) -> bool: + result = await db.execute( + select(UserCreditSubscription) + .where(UserCreditSubscription.id == subscription_id) + .with_for_update() + .limit(1) + ) + subscription = result.scalar_one_or_none() + if ( + subscription is None + or subscription.status != CreditSubscriptionStatus.ACTIVE.value + or subscription.expires_at > request_time + ): + return False + subscription.status = CreditSubscriptionStatus.EXPIRED.value + subscription.next_grant_at = None + await db.flush() + return True + + +async def expire_due_subscriptions( + db: AsyncSession, + *, + request_time: datetime | None = None, + batch_size: int = 500, +) -> int: + checked_at = request_time or utc_now() + ids = await list_due_subscription_ids(db, request_time=checked_at, batch_size=batch_size) + count = 0 + for subscription_id in ids: + if await expire_subscription_by_id( + db, subscription_id=subscription_id, request_time=checked_at + ): + count += 1 + return count + + +async def revoke_payment_order_credits( + db: AsyncSession, + *, + order: PaymentOrder, + reason: str, + request_time: datetime | None = None, +) -> float: + """支付现有退款流程的积分账本适配:只撤销该订单当前尚未消费的积分。""" + from app.services.credit.ledger_service import revoke_balances + + checked_at = request_time or utc_now() + await acquire_user_credit_lock(db, order.user_id) + balance_result = await db.execute( + select(UserCreditBalance) + .where( + UserCreditBalance.payment_order_id == order.id, + UserCreditBalance.user_id == order.user_id, + UserCreditBalance.unspent_amount > 0, + UserCreditBalance.valid_from <= checked_at, + UserCreditBalance.expires_at > checked_at, + UserCreditBalance.revoked_at.is_(None), + ) + .order_by(UserCreditBalance.id.asc()) + .with_for_update() + ) + balances = list(balance_result.scalars().all()) + revoked = sum((to_credit_decimal(item.unspent_amount) for item in balances), Decimal("0.00")) + if balances: + await revoke_balances( + db, + balances=balances, + description=f"{reason}:撤销订单未消费积分", + related_id=order.id, + biz_key=f"payment-order:{order.id}:refund-revoke", + request_time=checked_at, + ) + + if order.subscription_id: + subscription_result = await db.execute( + select(UserCreditSubscription) + .where(UserCreditSubscription.id == order.subscription_id) + .limit(1) + .with_for_update() + ) + subscription = subscription_result.scalar_one_or_none() + if subscription: + subscription.status = CreditSubscriptionStatus.REFUNDED.value + periods_result = await db.execute( + select(UserCreditSubscriptionPeriod) + .where( + UserCreditSubscriptionPeriod.subscription_id == subscription.id, + UserCreditSubscriptionPeriod.status.in_([ + CreditSubscriptionPeriodStatus.SCHEDULED.value, + CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value, + ]), + ) + .order_by(UserCreditSubscriptionPeriod.id.asc()) + .with_for_update() + ) + for period in periods_result.scalars().all(): + period.status = CreditSubscriptionPeriodStatus.CANCELLED.value + period.cancelled_at = checked_at + period.upgrade_order_id = None + period.reserved_at = None + await db.flush() + return float(revoked) diff --git a/video-gen-api/app/services/credit/time_policy.py b/video-gen-api/app/services/credit/time_policy.py new file mode 100644 index 00000000..450b5506 --- /dev/null +++ b/video-gen-api/app/services/credit/time_policy.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import calendar +from datetime import datetime, time, timedelta +from zoneinfo import ZoneInfo + +from app.services.credit.utils import ensure_aware + +BUSINESS_TZ = ZoneInfo("Asia/Shanghai") + + +def to_business_time(value: datetime) -> datetime: + return ensure_aware(value).astimezone(BUSINESS_TZ) + + +def add_natural_months(anchor_at: datetime, months: int) -> datetime: + """始终基于传入锚点计算自然月,月底压缩后下月恢复原锚点日。""" + anchor = to_business_time(anchor_at) + month_index = anchor.year * 12 + anchor.month - 1 + int(months) + year, month_zero = divmod(month_index, 12) + month = month_zero + 1 + target_day = min(anchor.day, calendar.monthrange(year, month)[1]) + return anchor.replace(year=year, month=month, day=target_day) + + +def natural_month_period(anchor_at: datetime, sequence: int) -> tuple[datetime, datetime]: + start = add_natural_months(anchor_at, sequence) + end = add_natural_months(anchor_at, sequence + 1) + return start, end + + +def next_local_midnight(value: datetime) -> datetime: + local = to_business_time(value) + next_day = local.date() + timedelta(days=1) + return datetime.combine(next_day, time.min, tzinfo=BUSINESS_TZ) + + +def last_usable_at(expires_at: datetime) -> datetime: + return ensure_aware(expires_at) - timedelta(microseconds=1) diff --git a/video-gen-api/app/services/credit/upgrade_service.py b/video-gen-api/app/services/credit/upgrade_service.py new file mode 100644 index 00000000..1b98fa70 --- /dev/null +++ b/video-gen-api/app/services/credit/upgrade_service.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_product import SubscriptionBillingCycle +from app.enums.credit_subscription import CreditSubscriptionPeriodStatus +from app.models.credit.product import CreditProduct +from app.models.credit.subscription_period import UserCreditSubscriptionPeriod +from app.models.payment_order import PaymentOrder +from app.models.user import User +from app.services.credit.locking import acquire_user_credit_lock +from app.services.credit.product_service import ( + ProductPriceQuote, + current_product_price, + get_active_subscription, +) +from app.services.credit.utils import to_credit_decimal + + +async def quote_and_reserve_product_purchase( + db: AsyncSession, + *, + user: User, + product: CreditProduct, + order_id: str, + request_time: datetime, +) -> ProductPriceQuote: + if product.is_credit_addon: + price = to_credit_decimal(product.price) + return ProductPriceQuote( + product=product, + purchase_scene="credit_addon", + price_type="regular", + base_price=price, + activity_price=None, + target_price=price, + deduction_amount=Decimal("0.00"), + payable_amount=price, + ) + + # 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期, + # 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。 + await acquire_user_credit_lock(db, user.id) + pending_result = await db.execute( + select(PaymentOrder.id).where( + PaymentOrder.user_id == user.id, + PaymentOrder.id != order_id, + PaymentOrder.status == "pending", + PaymentOrder.product_type == "subscription", + ).limit(1) + ) + if pending_result.scalar_one_or_none() is not None: + raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期") + current = await get_active_subscription( + db, + user.id, + request_time=request_time, + for_update=True, + ) + first_purchase = user.first_membership_paid_at is None + if not first_purchase and not bool(product.renewal_enabled): + raise ValueError("该订阅套餐暂未开放续费或升级") + if current is None: + base, activity, target, price_type = current_product_price( + product, + first_purchase=first_purchase, + request_time=request_time, + upgrade=False, + ) + return ProductPriceQuote( + product=product, + purchase_scene="first_purchase" if first_purchase else "renewal", + price_type=price_type, + base_price=base, + activity_price=activity, + target_price=target, + deduction_amount=Decimal("0.00"), + payable_amount=target, + ) + + if product.billing_cycle != current.billing_cycle: + raise ValueError("当前订阅有效,只能升级同周期更高等级套餐") + if int(product.tier_rank or 0) <= int(current.tier_rank or 0): + raise ValueError("当前订阅有效,不能提前续费或降级") + + base, activity, target, price_type = current_product_price( + product, + first_purchase=False, + request_time=request_time, + upgrade=True, + ) + period_ids: list[str] = [] + periods: list[UserCreditSubscriptionPeriod] = [] + deduction = Decimal("0.00") + if current.billing_cycle in { + SubscriptionBillingCycle.QUARTERLY.value, + SubscriptionBillingCycle.YEARLY.value, + }: + result = await db.execute( + select(UserCreditSubscriptionPeriod) + .where( + UserCreditSubscriptionPeriod.subscription_id == current.id, + UserCreditSubscriptionPeriod.scheduled_at > request_time, + UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value, + ) + .order_by(UserCreditSubscriptionPeriod.sequence.asc()) + .with_for_update() + ) + periods = list(result.scalars().all()) + for period in periods: + deduction += to_credit_decimal(period.allocated_paid_amount) + period_ids.append(period.id) + payable = target - deduction + if payable <= Decimal("0.00"): + raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理") + for period in periods: + period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value + period.upgrade_order_id = order_id + period.reserved_at = request_time + return ProductPriceQuote( + product=product, + purchase_scene="upgrade", + price_type=price_type, + base_price=base, + activity_price=activity, + target_price=target, + deduction_amount=deduction, + payable_amount=payable, + source_subscription_id=current.id, + upgrade_period_ids=tuple(period_ids), + ) + + +async def release_upgrade_reservation( + db: AsyncSession, + *, + order: PaymentOrder, + released_at: datetime, +) -> int: + period_ids = list(order.upgrade_period_ids_json or []) + if not period_ids: + return 0 + await acquire_user_credit_lock(db, order.user_id) + result = await db.execute( + select(UserCreditSubscriptionPeriod) + .where( + UserCreditSubscriptionPeriod.id.in_(period_ids), + UserCreditSubscriptionPeriod.upgrade_order_id == order.id, + UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value, + ) + .with_for_update() + ) + periods = list(result.scalars().all()) + for period in periods: + period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value + period.upgrade_order_id = None + period.reserved_at = None + await db.flush() + return len(periods) diff --git a/video-gen-api/app/services/credit/utils.py b/video-gen-api/app/services/credit/utils.py new file mode 100644 index 00000000..14d14cb2 --- /dev/null +++ b/video-gen-api/app/services/credit/utils.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal, ROUND_HALF_UP +from typing import Any + +CREDIT_QUANT = Decimal("0.01") + + +def to_credit_decimal(value: Any) -> Decimal: + try: + return Decimal(str(value or 0)).quantize(CREDIT_QUANT, rounding=ROUND_HALF_UP) + except Exception as exc: + raise ValueError(f"无效积分数值: {value!r}") from exc + + +def to_float(value: Decimal | int | float | None) -> float: + return float(to_credit_decimal(value)) + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def ensure_aware(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value diff --git a/video-gen-api/app/services/credits.py b/video-gen-api/app/services/credits.py index 6d377f12..d47ded76 100644 --- a/video-gen-api/app/services/credits.py +++ b/video-gen-api/app/services/credits.py @@ -1,5 +1,4 @@ -import math -from dataclasses import dataclass +from datetime import datetime from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -9,23 +8,11 @@ from app.models.credit_record import CreditRecord from app.models.video_engine import VideoEngine from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio -from app.utils.id_gen import generate_id -from app.utils.exceptions import InsufficientCreditsError -from app.enums.common import BillingBlockEventEnum -from app.services.operation_log_service import log_operation_event -from app.services.system_config_cache import get_system_config_value -from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float: - """Calculate text credits based on actual token usage and cached configurable rate.""" - raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens") - try: - rate = float(raw_rate) if raw_rate not in (None, "") else 1.0 - except (TypeError, ValueError): - rate = 1.0 - total_tokens = input_tokens + output_tokens - return round(total_tokens * rate / 1000, 2) + """历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。""" + return 0.0 async def _get_credit_ratio( @@ -176,31 +163,19 @@ async def calc_image_credits( return round(total, 2) -@dataclass(slots=True) -class CreditMutationResult: - user: User - record: CreditRecord | None - created: bool - amount: float - balance_before: float - balance_after: float - -async def _get_existing_credit_record_by_biz_key( - db: AsyncSession, - *, - user_id: str, - biz_key: str | None, -) -> CreditRecord | None: - """按正式业务幂等键查找已有积分流水。""" - if not biz_key: - return None - result = await db.execute( - select(CreditRecord) - .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key) - .limit(1) - ) - return result.scalar_one_or_none() +from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel +from app.enums.credit_record import CreditRecordType +from app.services.credit.ledger_service import ( + CreditMutationResult, + deduct_credits as deduct_dynamic_credits, + grant_credits, + refund_consumption, +) +from app.services.credit.query_service import get_available_credits +from app.services.credit.time_policy import add_natural_months +from app.services.credit.utils import to_float, utc_now +from app.services.credit_record_meta_service import CreditRecordMeta async def deduct_credits_result( @@ -216,94 +191,21 @@ async def deduct_credits_result( record_type: str = "consume", allow_negative: bool = False, create_zero_record: bool = False, + request_time: datetime | None = None, ) -> CreditMutationResult: - """并发安全且可观察幂等结果的积分扣减。""" - amount = round(float(amount or 0), 2) - result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1)) - user = result.scalar_one_or_none() - if not user: - raise ValueError("User not found") - - before_balance = round(float(user.credits or 0), 2) - if biz_key: - existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) - if existing: - return CreditMutationResult( - user=user, - record=existing, - created=False, - amount=abs(round(float(existing.amount or 0), 2)), - balance_before=before_balance, - balance_after=before_balance, - ) - - if amount <= 0 and not create_zero_record: - return CreditMutationResult( - user=user, - record=None, - created=False, - amount=0.0, - balance_before=before_balance, - balance_after=before_balance, - ) - - if amount > 0 and not allow_negative and before_balance < amount: - event_type = ( - BillingBlockEventEnum.NEGATIVE_BALANCE.value - if before_balance < 0 - else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value - ) - log_operation_event( - domain="billing", - module="credits", - event_type=event_type, - event_status="failed", - source="app.services.credits.deduct_credits_result", - user_id=user_id, - task_id=related_id, - message="积分不足,已拦截新的扣费请求", - detail={ - "user_id": user_id, - "amount": amount, - "before_balance": before_balance, - "allow_negative": allow_negative, - "biz_key": biz_key, - "refund_for_biz_key": refund_for_biz_key, - "description": description, - "record_type": record_type, - }, - ) - raise InsufficientCreditsError() - - user.credits = round(before_balance - max(0.0, amount), 2) - meta_kwargs: dict = {} - if record_meta: - if isinstance(record_meta, CreditRecordMeta): - record_meta = await with_user_snapshot(db, record_meta, user_id, user=user) - meta_kwargs = record_meta.to_record_kwargs() - elif isinstance(record_meta, dict): - meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} - record = CreditRecord( - id=generate_id(), + """旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。""" + return await deduct_dynamic_credits( + db, user_id=user_id, - type=record_type, - amount=-amount, - balance_after=user.credits, + amount=amount, description=description, related_id=related_id, biz_key=biz_key, refund_for_biz_key=refund_for_biz_key, - **meta_kwargs, - ) - db.add(record) - await db.flush() - return CreditMutationResult( - user=user, - record=record, - created=True, - amount=amount, - balance_before=before_balance, - balance_after=round(float(user.credits or 0), 2), + record_meta=record_meta, + record_type=record_type, + create_zero_record=create_zero_record, + request_time=request_time, ) @@ -320,22 +222,24 @@ async def deduct_credits( record_type: str = "consume", allow_negative: bool = False, create_zero_record: bool = False, + request_time: datetime | None = None, ) -> User: - """兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。""" - mutation = await deduct_credits_result( - db, - user_id=user_id, - amount=amount, - description=description, - related_id=related_id, - biz_key=biz_key, - refund_for_biz_key=refund_for_biz_key, - record_meta=record_meta, - record_type=record_type, - allow_negative=allow_negative, - create_zero_record=create_zero_record, - ) - return mutation.user + return ( + await deduct_credits_result( + db, + user_id=user_id, + amount=amount, + description=description, + related_id=related_id, + biz_key=biz_key, + refund_for_biz_key=refund_for_biz_key, + record_meta=record_meta, + record_type=record_type, + allow_negative=allow_negative, + create_zero_record=create_zero_record, + request_time=request_time, + ) + ).user async def add_credits_result( @@ -349,66 +253,63 @@ async def add_credits_result( biz_key: str | None = None, refund_for_biz_key: str | None = None, record_meta: CreditRecordMeta | dict | None = None, + valid_from: datetime | None = None, + expires_at: datetime | None = None, + credit_level: str = CreditLevel.GENERAL.value, + source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value, + source_id: str | None = None, + product_id: str | None = None, + payment_order_id: str | None = None, + subscription_id: str | None = None, + subscription_period_id: str | None = None, + request_time: datetime | None = None, ) -> CreditMutationResult: - """并发安全且可观察幂等结果的积分增加。""" - amount = round(float(amount or 0), 2) - result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1)) - user = result.scalar_one_or_none() - if not user: - raise ValueError("User not found") - - before_balance = round(float(user.credits or 0), 2) - if biz_key: - existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) - if existing: - return CreditMutationResult( - user=user, - record=existing, - created=False, - amount=abs(round(float(existing.amount or 0), 2)), - balance_before=before_balance, - balance_after=before_balance, - ) - - if amount <= 0: + checked_at = request_time or utc_now() + if record_type == CreditRecordType.REFUND.value and refund_for_biz_key: + refunded = await refund_consumption( + db, + user_id=user_id, + refund_for_biz_key=refund_for_biz_key, + description=description, + related_id=related_id, + biz_key=biz_key, + record_meta=record_meta, + refund_time=checked_at, + ) + user_result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = user_result.scalar_one() + setattr(user, "credits", to_float(refunded.balance_after)) + record = refunded.records[0] if refunded.records else None return CreditMutationResult( user=user, - record=None, - created=False, - amount=0.0, - balance_before=before_balance, - balance_after=before_balance, + record=record, + created=refunded.created, + amount=to_float(refunded.total_amount), + balance_before=to_float(refunded.balance_before), + balance_after=to_float(refunded.balance_after), + refund_available=to_float(refunded.available_amount), + refund_expired=to_float(refunded.expired_amount), ) - - user.credits = round(before_balance + amount, 2) - meta_kwargs: dict = {} - if record_meta: - if isinstance(record_meta, CreditRecordMeta): - record_meta = await with_user_snapshot(db, record_meta, user_id, user=user) - meta_kwargs = record_meta.to_record_kwargs() - elif isinstance(record_meta, dict): - meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} - record = CreditRecord( - id=generate_id(), + starts_at = valid_from or checked_at + return await grant_credits( + db, user_id=user_id, - type=record_type, amount=amount, - balance_after=user.credits, description=description, + source_type=source_type, + valid_from=starts_at, + expires_at=expires_at or add_natural_months(starts_at, 1), + credit_level=credit_level, + source_id=source_id or related_id, + product_id=product_id, + payment_order_id=payment_order_id, + subscription_id=subscription_id, + subscription_period_id=subscription_period_id, related_id=related_id, + record_type=record_type, biz_key=biz_key, - refund_for_biz_key=refund_for_biz_key, - **meta_kwargs, - ) - db.add(record) - await db.flush() - return CreditMutationResult( - user=user, - record=record, - created=True, - amount=amount, - balance_before=before_balance, - balance_after=round(float(user.credits or 0), 2), + record_meta=record_meta, + request_time=checked_at, ) @@ -423,20 +324,40 @@ async def add_credits( biz_key: str | None = None, refund_for_biz_key: str | None = None, record_meta: CreditRecordMeta | dict | None = None, + valid_from: datetime | None = None, + expires_at: datetime | None = None, + credit_level: str = CreditLevel.GENERAL.value, + source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value, + source_id: str | None = None, + product_id: str | None = None, + payment_order_id: str | None = None, + subscription_id: str | None = None, + subscription_period_id: str | None = None, + request_time: datetime | None = None, ) -> User: - """兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。""" - mutation = await add_credits_result( - db, - user_id=user_id, - amount=amount, - description=description, - related_id=related_id, - record_type=record_type, - biz_key=biz_key, - refund_for_biz_key=refund_for_biz_key, - record_meta=record_meta, - ) - return mutation.user + return ( + await add_credits_result( + db, + user_id=user_id, + amount=amount, + description=description, + related_id=related_id, + record_type=record_type, + biz_key=biz_key, + refund_for_biz_key=refund_for_biz_key, + record_meta=record_meta, + valid_from=valid_from, + expires_at=expires_at, + credit_level=credit_level, + source_type=source_type, + source_id=source_id, + product_id=product_id, + payment_order_id=payment_order_id, + subscription_id=subscription_id, + subscription_period_id=subscription_period_id, + request_time=request_time, + ) + ).user async def refund_credits( @@ -449,30 +370,41 @@ async def refund_credits( biz_key: str | None = None, refund_for_biz_key: str | None = None, record_meta: CreditRecordMeta | dict | None = None, + request_time: datetime | None = None, ) -> User: - """生成失败积分回退。""" + if not refund_for_biz_key: + raise ValueError("动态积分退款必须指定原消费 biz_key") return await add_credits( db, user_id=user_id, amount=amount, description=description, related_id=related_id, - record_type="refund", + record_type=CreditRecordType.REFUND.value, biz_key=biz_key, refund_for_biz_key=refund_for_biz_key, record_meta=record_meta, + request_time=request_time, ) -async def get_records(db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20) -> tuple[list[CreditRecord], int]: +async def get_credit_balance(db: AsyncSession, user_id: str, request_time: datetime | None = None) -> float: + return to_float(await get_available_credits(db, user_id, request_time=request_time or utc_now())) + + +async def get_records( + db: AsyncSession, + user_id: str, + page: int = 1, + page_size: int = 20, +) -> tuple[list[CreditRecord], int]: count_query = select(func.count(CreditRecord.id)).where(CreditRecord.user_id == user_id) total = (await db.execute(count_query)).scalar() or 0 - result = await db.execute( select(CreditRecord) .where(CreditRecord.user_id == user_id) - .order_by(CreditRecord.created_at.desc()) + .order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc()) .offset((page - 1) * page_size) .limit(page_size) ) - return list(result.scalars().all()), total + return list(result.scalars().all()), int(total) diff --git a/video-gen-api/app/services/generation/billing_service.py b/video-gen-api/app/services/generation/billing_service.py index f1d8c6bc..3b5262d8 100644 --- a/video-gen-api/app/services/generation/billing_service.py +++ b/video-gen-api/app/services/generation/billing_service.py @@ -38,7 +38,7 @@ OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.va OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value _BIZ_KEY_PATTERN = re.compile( - r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|hold|hold_release)$" + r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|pre_deduct|hold|hold_release)$" ) @@ -101,8 +101,8 @@ def build_credit_biz_key( owner_id = owner_id.strip() charge_kind = charge_kind.strip() action = action.strip() - if action not in ("charge", "refund", "hold", "hold_release"): - raise ValueError("action 仅支持 charge/refund/hold/hold_release") + if action not in ("charge", "refund", "pre_deduct", "hold", "hold_release"): + raise ValueError("action 仅支持 charge/refund/pre_deduct/hold/hold_release") if attempt_no <= 0: raise ValueError("attempt_no 必须大于 0") return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}" diff --git a/video-gen-api/app/services/generation/prompt_optimize_service.py b/video-gen-api/app/services/generation/prompt_optimize_service.py index e7bb75d2..18b11de6 100644 --- a/video-gen-api/app/services/generation/prompt_optimize_service.py +++ b/video-gen-api/app/services/generation/prompt_optimize_service.py @@ -28,7 +28,6 @@ from app.enums.generation_status import ( GenerationStatus, GenerationType, ) -from app.enums.llm_billing import LlmBillingConfigKey from app.models.generation_record import GenerationRecord from app.models.project import Project from app.schemas.generation import OptimizeParams @@ -51,12 +50,12 @@ from app.services.generation.pipeline.generation_record_config_service import ( from app.services.llm import optimize_prompt from app.services.llm_billing import ( LlmBillingContext, - log_provider_failure, + record_provider_exception, log_provider_start, log_provider_success, - release_on_failure, - settle_success, - start_hold, + finalize_llm_business_failure, + mark_business_success, + pre_deduct, ) from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot @@ -110,7 +109,6 @@ def _billing_context(*, user_id: str, record_id: str, request_id: str | None) -> billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value, source_module=CreditRecordSourceModule.GENERATION_RECORD.value, related_id=record_id, - hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, description_prefix="AI创作提示词优化", trace_id=f"generation-optimize:{record_id}", request_id=request_id, @@ -246,14 +244,14 @@ async def _settle_staged_result( detail={"status": status_snapshot, "attempt_no": _PROMPT_ATTEMPT_NO}, ) try: - billing = await settle_success( + billing = await mark_business_success( db, ctx, usage=usage, description=f"提示词优化 - {project_name}", ) charge_item = next( - (item for item in billing.items if item.biz_key == ctx.charge_biz_key), + (item for item in billing.items if item.biz_key == ctx.billing_biz_key), None, ) record.text_credits_cost = round(float(charge_item.amount if charge_item else 0.0), 2) @@ -468,7 +466,7 @@ async def optimize_generation_prompt( ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key) try: - await start_hold(db, ctx) + await pre_deduct(db, ctx) await db.commit() except Exception: await db.rollback() @@ -488,7 +486,7 @@ async def optimize_generation_prompt( }, ) - log_provider_start(ctx, detail={"gen_type": req.gen_type.value}) + await log_provider_start(db, ctx, detail={"gen_type": req.gen_type.value}) try: optimized_prompt, usage = await optimize_prompt( db, @@ -507,11 +505,15 @@ async def optimize_generation_prompt( log_owner_type=OWNER_GENERATION_RECORD, log_owner_id=record_id, generation_attempt_no=_PROMPT_ATTEMPT_NO, + fixed_model_config_id=ctx.model_config_id, + fixed_model_snapshot=ctx.model_parameters_snapshot, ) - log_provider_success(ctx, usage=usage) + await log_provider_success(db, ctx, usage=usage) except Exception as exc: await db.rollback() - log_provider_failure(ctx, error=str(exc)) + provider_succeeded, recovered_usage = await record_provider_exception(db, ctx, exc) + if recovered_usage: + usage = recovered_usage compensated = False try: failed_result = await db.execute( @@ -521,12 +523,21 @@ async def optimize_generation_prompt( .limit(1) ) failed_record = failed_result.scalar_one_or_none() + business_already_succeeded = bool( + failed_record is not None + and failed_record.status in { + GenerationStatus.prompt_optimized.value, + GenerationStatus.generating.value, + GenerationStatus.completed.value, + } + ) if failed_record is not None and failed_record.status == GenerationStatus.optimizing.value: failed_record.status = GenerationStatus.failed.value failed_record.error_message = extract_error_message(exc, "提示词") - await release_on_failure(db, ctx, error=str(exc)) - compensated = True await db.commit() + if not business_already_succeeded: + await finalize_llm_business_failure(ctx, error=str(exc)) + compensated = True except Exception: await db.rollback() logger.exception("prompt optimize failure compensation failed: record_id=%s", record_id) @@ -613,6 +624,7 @@ async def optimize_generation_prompt( await db.rollback() logger.exception("prompt optimize provider result staging failed: record_id=%s", record_id) if not staged: + failure = last_stage_error or RuntimeError("unknown staging failure") log_operation_error( domain=_LOG_DOMAIN, event_type=GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED.value, @@ -622,9 +634,45 @@ async def optimize_generation_prompt( project_id=project_id, task_id=record_id, detail={"attempt_no": _PROMPT_ATTEMPT_NO, "stage": "provider_result_persistence"}, - exc=last_stage_error or RuntimeError("unknown staging failure"), + exc=failure, ) - raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,请联系管理员根据模型日志处理") + # 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后, + # 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按 + # 原积分来源退回固定预扣,不能让账务永久停留在 processing。 + business_already_succeeded = False + try: + await db.rollback() + failed_result = await db.execute( + select(GenerationRecord) + .where( + GenerationRecord.id == record_id, + GenerationRecord.user_id == user_id, + GenerationRecord.deleted_at.is_(None), + ) + .with_for_update() + .limit(1) + ) + failed_record = failed_result.scalar_one_or_none() + business_already_succeeded = bool( + failed_record is not None + and failed_record.status in { + GenerationStatus.prompt_optimized.value, + GenerationStatus.generating.value, + GenerationStatus.completed.value, + } + ) + if failed_record is not None and not business_already_succeeded: + failed_record.status = GenerationStatus.failed.value + failed_record.pipeline_stage = None + failed_record.error_message = "提词已生成但本地结果暂存失败" + await db.commit() + except Exception: + await db.rollback() + logger.exception("prompt optimize staging final failure persistence failed: record_id=%s", record_id) + if business_already_succeeded: + return PromptOptimizeServiceResult(record_id=record_id, idempotent=True) + await finalize_llm_business_failure(ctx, error=str(failure)) + raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,固定预扣积分已按原来源退回") _log_event( GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED, diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py index 94b4dfa8..ed9dad95 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType -from app.enums.llm_billing import LlmBillingConfigKey from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum from app.models.chat_generation_task import ChatGenerationTask from app.models.module_generation_project import ModuleGenerationProject @@ -55,13 +54,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod from app.services.llm import optimize_prompt from app.services.llm_billing import ( LlmBillingContext, - ensure_hold_exists, + ensure_pre_deducted, log_provider_failure, + record_provider_exception, log_provider_start, log_provider_success, - release_on_failure, - settle_success, - start_hold, + refund_on_final_failure, + finalize_llm_business_failure, + mark_business_success, + pre_deduct, ) from app.services.module_generation_flow_base_service import ( assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks, @@ -892,7 +893,7 @@ async def submit_image_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.error_message = None - await start_hold( + await pre_deduct( db, LlmBillingContext( user_id=str(project.user_id), @@ -906,9 +907,8 @@ async def submit_image_prompt_optimize( source_step_id=str(step.id), source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, related_id=str(step.id), - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, description_prefix="爆款开头复刻图片AI提词优化", - trace_id=f"llm-submit-hold:{step.id}", + trace_id=f"llm-submit-pre-deduct:{step.id}", ), ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") @@ -1005,14 +1005,13 @@ async def run_image_prompt_optimize( source_step_id=step_id_value, source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, related_id=step_id_value, - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, - description_prefix="爆款开头复刻图片AI提词优化", + description_prefix="爆款开头复刻图片AI提词优化", trace_id=f"hot-opening-image-prompt:{step_id_value}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -1023,7 +1022,7 @@ async def run_image_prompt_optimize( provider_succeeded = False token_usage: dict[str, Any] = {} - log_provider_start(llm_billing_context, detail={"prompt_type": "image"}) + await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"}) try: request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} log_module_prompt_event( @@ -1048,9 +1047,11 @@ async def run_image_prompt_optimize( log_owner_type="module_generation_step", log_owner_id=step_id_value, generation_attempt_no=expected_step_version, + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=token_usage) + await log_provider_success(db, llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1064,16 +1065,10 @@ async def run_image_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - # Provider 已成功,旧步骤即使失效也必须按真实 usage 结算,不能免费释放。 - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-图片AI提词优化(失效结果结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用") + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用") return None - billing = await settle_success( + billing = await mark_business_success( db, llm_billing_context, usage=token_usage, @@ -1121,21 +1116,19 @@ async def run_image_prompt_optimize( except DatabaseRowLockBusy: await db.rollback() if provider_succeeded: - # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-图片AI提词优化(行锁失败结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库") + await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库") return None # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() - if not provider_succeeded: - log_provider_failure(llm_billing_context, error=str(exc)) + if provider_succeeded: + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc) + if recovered_usage: + token_usage = recovered_usage if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1150,16 +1143,7 @@ async def run_image_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-图片AI提词优化(异常失效结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") - await db.commit() + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败") return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) if str(exc) else type(exc).__name__ @@ -1178,16 +1162,8 @@ async def run_image_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-图片AI提词优化(本地失败结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) return step @@ -1351,7 +1327,7 @@ async def submit_video_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.error_message = None - await start_hold( + await pre_deduct( db, LlmBillingContext( user_id=str(project.user_id), @@ -1365,9 +1341,8 @@ async def submit_video_prompt_optimize( source_step_id=str(step.id), source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, related_id=str(step.id), - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, description_prefix="爆款开头复刻视频AI提词优化", - trace_id=f"llm-submit-hold:{step.id}", + trace_id=f"llm-submit-pre-deduct:{step.id}", ), ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") @@ -1462,14 +1437,13 @@ async def run_video_prompt_optimize( source_step_id=step_id_value, source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, related_id=step_id_value, - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, - description_prefix="爆款开头复刻视频AI提词优化", + description_prefix="爆款开头复刻视频AI提词优化", trace_id=f"hot-opening-video-prompt:{step_id_value}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -1480,7 +1454,7 @@ async def run_video_prompt_optimize( provider_succeeded = False token_usage: dict[str, Any] = {} - log_provider_start(llm_billing_context, detail={"prompt_type": "video"}) + await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"}) try: request_log = { "source_project_name": material.get("source_project_name") or "无", @@ -1517,9 +1491,11 @@ async def run_video_prompt_optimize( project_id=project_id_value, step_id=step_id_value, trace_id=f"hot-video-prompt:{step_id_value}", + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=token_usage) + await log_provider_success(db, llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1533,15 +1509,10 @@ async def run_video_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-视频AI提词优化(失效结果结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用") + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用") return None - billing = await settle_success( + billing = await mark_business_success( db, llm_billing_context, usage=token_usage, @@ -1592,21 +1563,19 @@ async def run_video_prompt_optimize( except DatabaseRowLockBusy: await db.rollback() if provider_succeeded: - # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-视频AI提词优化(行锁失败结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库") + await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库") return None # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() - if not provider_succeeded: - log_provider_failure(llm_billing_context, error=str(exc)) + if provider_succeeded: + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc) + if recovered_usage: + token_usage = recovered_usage if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1621,16 +1590,7 @@ async def run_video_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-视频AI提词优化(异常失效结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") - await db.commit() + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败") return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) if str(exc) else type(exc).__name__ @@ -1649,16 +1609,8 @@ async def run_video_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="爆款开头复刻-视频AI提词优化(本地失败结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) return step @@ -1946,7 +1898,7 @@ async def mark_hot_opening_step_dispatch_failed( project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = error_message if step.step_code in (HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value): - await release_on_failure( + await refund_on_final_failure( db, LlmBillingContext( user_id=str(project.user_id), @@ -1964,11 +1916,6 @@ async def mark_hot_opening_step_dispatch_failed( source_step_id=str(step.id), source_step_code=str(step.step_code), related_id=str(step.id), - hold_config_key=( - LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value - if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value - else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value - ), description_prefix=( "爆款开头复刻图片AI提词优化" if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py index 98445d03..cf11750f 100644 --- a/video-gen-api/app/services/hot_opening_video_prompt_service.py +++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py @@ -15,6 +15,7 @@ from app.enums.common import LogEventStatusEnum, LogSourceEnum from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum from app.services.operation_log_service import log_ai_model_event +from app.services.llm_billing.context import LlmProviderPostprocessError from app.enums.common import ( VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE, VIDEO_SCHEMA_CONFIG_VERSION, @@ -1545,8 +1546,15 @@ def _log_video_prompt_ai_event( **common, ) -async def _select_model_config(db: AsyncSession) -> ModelConfig | None: - result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)).order_by(ModelConfig.priority.desc()).limit(1)) +async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None: + stmt = select(ModelConfig).where( + ModelConfig.is_active == True, + ModelConfig.deleted_at.is_(None), + ModelConfig.provider != "mock", + ) + if model_config_id: + stmt = stmt.where(ModelConfig.id == model_config_id) + result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)) return result.scalar_one_or_none() @@ -1566,6 +1574,8 @@ async def optimize_hot_opening_video_prompt( project_id: str | None = None, step_id: str | None = None, trace_id: str | None = None, + fixed_model_config_id: str | None = None, + fixed_model_snapshot: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], str, dict[str, Any]]: call_id = generate_id() duration = int(video_config["duration"]) @@ -1580,15 +1590,18 @@ async def optimize_hot_opening_video_prompt( # result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration)) # return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} - config_row = await _select_model_config(db) + config_row = await _select_model_config(db, fixed_model_config_id) + snapshot = dict(fixed_model_snapshot or {}) config = ( SimpleNamespace( id=str(config_row.id), - name=str(config_row.name or ""), - provider=str(config_row.provider or ""), - api_base=str(config_row.api_base or ""), + name=str(snapshot.get("name") or config_row.name or ""), + provider=str(snapshot.get("provider") or config_row.provider or ""), + api_base=str(snapshot.get("api_base") or config_row.api_base or ""), api_key=str(config_row.api_key or ""), - model_name=str(config_row.model_name or ""), + model_name=str(snapshot.get("model_name") or config_row.model_name or ""), + max_tokens=snapshot.get("max_tokens"), + temperature=snapshot.get("temperature"), ) if config_row is not None else None @@ -1597,14 +1610,7 @@ async def optimize_hot_opening_video_prompt( # 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。 await db.rollback() if not config: - result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot) - return result, build_final_video_prompt(result), { - "input_tokens": 0, - "output_tokens": 0, - "total_tokens": 0, - "usage_reported": True, - "billing_free": True, - } + raise RuntimeError("固定LLM模型不存在、已停用或为Mock模型,禁止自动切换和降级") if use_base64: video_url_final = await media_to_base64(material_video_url, "video/mp4") @@ -1720,13 +1726,10 @@ async def optimize_hot_opening_video_prompt( try: data = response.json() - content = data["choices"][0]["message"]["content"].strip() - if not content: - raise RuntimeError("视频提词模型响应 content 为空") except Exception as exc: - parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc)) + parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True) _log_video_prompt_ai_event( - call_id=call_id, + call_id=call_id, module=module, event_type=parse_event, action=remote_action, @@ -1740,7 +1743,7 @@ async def optimize_hot_opening_video_prompt( response_data=response_data, http_status=response.status_code, remote_request_id=remote_request_id, - message="视频提词模型响应解析失败", + message="视频提词模型响应 JSON 解析失败", error=str(exc), ) raise @@ -1765,6 +1768,9 @@ async def optimize_hot_opening_video_prompt( }) try: + content = data["choices"][0]["message"]["content"].strip() + if not content: + raise RuntimeError("视频提词模型响应 content 为空") result = parse_model_json(content) result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot) except Exception as exc: @@ -1787,7 +1793,7 @@ async def optimize_hot_opening_video_prompt( message="视频提词业务 JSON 解析失败", error=str(exc), ) - raise + raise LlmProviderPostprocessError(f"视频提词响应后处理失败: {exc}", usage=token_usage) from exc success_event, remote_action = _video_prompt_remote_event(module, success=True) _log_video_prompt_ai_event( call_id=call_id, diff --git a/video-gen-api/app/services/llm.py b/video-gen-api/app/services/llm.py index fe7ac339..c8fa656a 100644 --- a/video-gen-api/app/services/llm.py +++ b/video-gen-api/app/services/llm.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.model_config import ModelConfig from app.services.operation_log_service import build_exception_detail, log_ai_model_event +from app.services.llm_billing.context import LlmProviderPostprocessError from app.utils.id_gen import generate_id @@ -74,66 +75,52 @@ async def optimize_prompt( log_owner_type: str | None = None, log_owner_id: str | None = None, generation_attempt_no: int | None = None, + fixed_model_config_id: str | None = None, + fixed_model_snapshot: dict | None = None, ) -> tuple[str, dict]: """Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict).""" - result = await db.execute( - select(ModelConfig) - .where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)) - .order_by(ModelConfig.priority.desc()) + stmt = select(ModelConfig).where( + ModelConfig.is_active.is_(True), + ModelConfig.deleted_at.is_(None), + ModelConfig.provider != "mock", + ) + if fixed_model_config_id: + stmt = stmt.where(ModelConfig.id == fixed_model_config_id) + stmt = stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1) + result = await db.execute(stmt) + item = result.scalar_one_or_none() + if item is None: + raise LLMProviderCallError("没有可用的固定LLM模型,本版本禁止Mock、自动切换和降级") + snapshot = dict(fixed_model_snapshot or {}) + selected = SimpleNamespace( + id=item.id, + name=snapshot.get("name") or item.name, + provider=snapshot.get("provider") or item.provider, + api_base=snapshot.get("api_base") or item.api_base, + api_key=item.api_key, + model_name=snapshot.get("model_name") or item.model_name, + max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else item.max_tokens, + temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else item.temperature, ) - configs = [ - SimpleNamespace( - id=item.id, - name=item.name, - provider=item.provider, - api_base=item.api_base, - api_key=item.api_key, - model_name=item.model_name, - max_tokens=item.max_tokens, - temperature=item.temperature, - ) - for item in result.scalars().all() - ] - # Release the read transaction before the external LLM request. Only - # plain scalar snapshots are used afterwards, so expire_on_commit does - # not trigger an ORM refresh while the provider request is in flight. await db.commit() - - if configs: - # 按 priority 从大到小依次尝试,跳过 mock,失败则用下一个 - for selected in configs: - if selected.provider == "mock": - continue - if selected.provider in ("openai_compatible", "sdk"): - try: - return await _call_openai_compatible( - selected, original_prompt, db, user_id, industry_key, duration, - references=references, - gen_type=gen_type, - image_size=image_size, - image_proportion=image_proportion, - image_px=image_px, - log_module=log_module, - log_step=log_step, - log_project_id=log_project_id, - log_task_id=log_task_id, - log_owner_type=log_owner_type, - log_owner_id=log_owner_id, - generation_attempt_no=generation_attempt_no, - ) - except LLMProviderCallError: - continue - - # 所有真实模型都失败,降级到 mock - mock_cfg = next((c for c in configs if c.provider == "mock"), None) - if mock_cfg: - return _mock_optimize(original_prompt, gen_type) - - if settings.LLM_MOCK: - return _mock_optimize(original_prompt, gen_type) - - return _get_default_prompt(original_prompt, gen_type) + if selected.provider not in ("openai_compatible", "sdk"): + raise LLMProviderCallError(f"固定模型供应商不受支持:{selected.provider}") + return await _call_openai_compatible( + selected, original_prompt, db, user_id, industry_key, duration, + references=references, + gen_type=gen_type, + image_size=image_size, + image_proportion=image_proportion, + image_px=image_px, + log_module=log_module, + log_step=log_step, + log_project_id=log_project_id, + log_task_id=log_task_id, + log_owner_type=log_owner_type, + log_owner_id=log_owner_id, + generation_attempt_no=generation_attempt_no, + ) def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]: @@ -435,19 +422,28 @@ async def _call_openai_compatible( ) raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc + raw_usage = data.get("usage") if isinstance(data, dict) else None + usage_reported = bool( + isinstance(raw_usage, dict) + and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens")) + ) + usage = raw_usage if isinstance(raw_usage, dict) else {} + input_tokens = int(usage.get("prompt_tokens", 0) or 0) + output_tokens = int(usage.get("completion_tokens", 0) or 0) + total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0) + token_usage = { + "model_config_id": config.id, + "model_config_name": config.name, + "model_provider": config.provider, + "model_name": config.model_name, + "source_module": log_module, + "source_step_code": log_step, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "usage_reported": usage_reported, + } try: - raw_usage = data.get("usage") - usage_reported = bool( - isinstance(raw_usage, dict) - and any( - key in raw_usage - for key in ("prompt_tokens", "completion_tokens", "total_tokens") - ) - ) - usage = raw_usage if isinstance(raw_usage, dict) else {} - input_tokens = int(usage.get("prompt_tokens", 0) or 0) - output_tokens = int(usage.get("completion_tokens", 0) or 0) - total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0) content = data["choices"][0]["message"]["content"].strip() if not content: raise ValueError("模型未返回有效提示词") @@ -461,18 +457,5 @@ async def _call_openai_compatible( error=str(exc), **common_log, ) - raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc - - token_usage = { - "model_config_id": config.id, - "model_config_name": config.name, - "model_provider": config.provider, - "model_name": config.model_name, - "source_module": log_module, - "source_step_code": log_step, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_tokens": total_tokens, - "usage_reported": usage_reported, - } + raise LlmProviderPostprocessError(f"模型响应解析失败: {exc}", usage=token_usage) from exc return content, token_usage diff --git a/video-gen-api/app/services/llm_billing/__init__.py b/video-gen-api/app/services/llm_billing/__init__.py index 5e49ee5d..9dcbb82c 100644 --- a/video-gen-api/app/services/llm_billing/__init__.py +++ b/video-gen-api/app/services/llm_billing/__init__.py @@ -3,22 +3,24 @@ from app.services.llm_billing.context import ( LlmBillingContext, LlmBillingPolicy, LlmBillingStateError, - LlmHoldResult, - LlmHoldValidation, + LlmPreDeductResult, + LlmPreDeductValidation, ) from app.services.llm_billing.service import ( - ensure_hold_exists, + ensure_pre_deducted, get_llm_ledger_states, log_celery_dispatch_compensated, log_celery_dispatch_failure, log_celery_dispatch_start, log_celery_dispatch_success, log_provider_failure, + record_provider_exception, log_provider_start, log_provider_success, - release_on_failure, - settle_success, - start_hold, + refund_on_final_failure, + finalize_llm_business_failure, + mark_business_success, + pre_deduct, validate_retryable_previous_attempt, ) @@ -27,19 +29,21 @@ __all__ = [ "LlmBillingContext", "LlmBillingPolicy", "LlmBillingStateError", - "LlmHoldResult", - "LlmHoldValidation", - "start_hold", - "ensure_hold_exists", + "LlmPreDeductResult", + "LlmPreDeductValidation", + "pre_deduct", + "ensure_pre_deducted", "get_llm_ledger_states", "validate_retryable_previous_attempt", "log_provider_start", "log_provider_success", "log_provider_failure", + "record_provider_exception", "log_celery_dispatch_start", "log_celery_dispatch_success", "log_celery_dispatch_failure", "log_celery_dispatch_compensated", - "settle_success", - "release_on_failure", + "mark_business_success", + "refund_on_final_failure", + "finalize_llm_business_failure", ] diff --git a/video-gen-api/app/services/llm_billing/call_audit_service.py b/video-gen-api/app/services/llm_billing/call_audit_service.py new file mode 100644 index 00000000..331b14aa --- /dev/null +++ b/video-gen-api/app/services/llm_billing/call_audit_service.py @@ -0,0 +1,271 @@ +from __future__ import annotations + +from typing import Any, Mapping + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.llm_billing import LlmBillingExecutionStatus, LlmCallAttemptStatus +from app.models.base import AsyncSessionLocal +from app.models.credit_record import CreditRecord +from app.models.llm_billing.call_attempt import LlmCallAttempt +from app.models.llm_billing.execution import LlmBillingExecution +from app.models.token_usage import TokenUsage +from app.services.credit.utils import utc_now +from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError +from app.utils.id_gen import generate_id + + +def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int: + for key in keys: + value = (usage or {}).get(key) + if value is not None: + try: + return max(0, int(value)) + except (TypeError, ValueError): + pass + return 0 + + +async def create_call_attempt( + ctx: LlmBillingContext, + *, + detail: Mapping[str, Any] | None = None, +) -> str: + async with AsyncSessionLocal() as db: + async with db.begin(): + result = await db.execute( + select(LlmBillingExecution) + .where(LlmBillingExecution.id == ctx.billing_execution_id) + .limit(1) + .with_for_update() + ) + execution = result.scalar_one_or_none() + if execution is None: + raise LlmBillingStateError("LLM固定预扣执行记录不存在,禁止调用模型") + if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id: + raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换") + if execution.status not in { + LlmBillingExecutionStatus.PRE_DEDUCTED.value, + LlmBillingExecutionStatus.PROCESSING.value, + }: + raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}") + max_result = await db.execute( + select(func.coalesce(func.max(LlmCallAttempt.call_sequence), 0)).where( + LlmCallAttempt.billing_execution_id == execution.id + ) + ) + sequence = int(max_result.scalar_one() or 0) + 1 + attempt = LlmCallAttempt( + id=generate_id(), + billing_execution_id=execution.id, + call_sequence=sequence, + retry_sequence=max(0, sequence - 1), + model_config_id=execution.model_config_id or ctx.model_config_id, + model_name_snapshot=execution.model_name_snapshot or ctx.model_name, + provider_snapshot=execution.provider_snapshot or ctx.provider, + request_started_at=utc_now(), + status=LlmCallAttemptStatus.STARTED.value, + postprocess_status="pending", + ) + db.add(attempt) + execution.status = LlmBillingExecutionStatus.PROCESSING.value + execution.total_call_count += 1 + ctx.current_call_attempt_id = attempt.id + # 同一个业务 execution 可能有多次供应商调用;每个新调用必须清空上一调用的 + # 成功事实和用量快照,避免下一次真实 provider failure 被误判为后处理失败。 + ctx.provider_call_succeeded = False + ctx.provider_usage_snapshot = None + ctx.token_usage_id = None + return attempt.id + + +async def _get_or_create_token_usage( + db: AsyncSession, + *, + ctx: LlmBillingContext, + attempt: LlmCallAttempt, + usage: Mapping[str, Any] | None, + input_tokens: int, + output_tokens: int, + total_tokens: int, +) -> TokenUsage: + """先持久化 TokenUsage,再允许任何外键引用它。 + + LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy + 无法仅凭字符串赋值推导 INSERT/UPDATE 顺序,所以必须显式 flush TokenUsage。 + 同时按 user_id + biz_key 复用记录,保证审计重试幂等。 + """ + biz_key = f"llm-call:{attempt.id}" + existing_result = await db.execute( + select(TokenUsage) + .where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key) + .limit(1) + ) + existing = existing_result.scalar_one_or_none() + if existing is not None: + return existing + + usage_row = TokenUsage( + id=generate_id(), + model_config_id=(usage or {}).get("model_config_id") or attempt.model_config_id, + user_id=ctx.user_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + owner_type=ctx.owner_type, + owner_id=ctx.owner_id, + biz_key=biz_key, + source_module=ctx.source_module, + source_step_code=ctx.source_step_code, + ) + try: + async with db.begin_nested(): + db.add(usage_row) + # 关键:先确保 token_usage 已 INSERT,后续 attempt.token_usage_id UPDATE + # 才不会违反 llm_call_attempts_token_usage_id_fkey。 + await db.flush([usage_row]) + except IntegrityError: + # 并发或审计重试可能已经创建同一 biz_key,回查复用。 + existing_result = await db.execute( + select(TokenUsage) + .where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key) + .limit(1) + ) + existing = existing_result.scalar_one_or_none() + if existing is None: + raise + return existing + return usage_row + + +async def finish_call_success( + ctx: LlmBillingContext, + *, + usage: Mapping[str, Any] | None = None, +) -> None: + if not ctx.current_call_attempt_id: + await create_call_attempt(ctx) + now = utc_now() + input_tokens = _usage_int(usage, "input_tokens", "prompt_tokens") + output_tokens = _usage_int(usage, "output_tokens", "completion_tokens") + total_tokens = _usage_int(usage, "total_tokens") or input_tokens + output_tokens + token_usage_id: str | None = None + async with AsyncSessionLocal() as db: + async with db.begin(): + result = await db.execute( + select(LlmCallAttempt) + .where(LlmCallAttempt.id == ctx.current_call_attempt_id) + .limit(1) + .with_for_update() + ) + attempt = result.scalar_one_or_none() + if attempt is None: + raise LlmBillingStateError("LLM调用审计记录不存在") + if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value: + ctx.token_usage_id = attempt.token_usage_id + return + previous_status = attempt.status + usage_row = await _get_or_create_token_usage( + db, + ctx=ctx, + attempt=attempt, + usage=usage, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) + token_usage_id = usage_row.id + + attempt.status = LlmCallAttemptStatus.SUCCEEDED.value + attempt.response_received_at = now + attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000)) + attempt.input_tokens = input_tokens + attempt.output_tokens = output_tokens + attempt.total_tokens = total_tokens + attempt.token_usage_id = token_usage_id + attempt.provider_request_id = (usage or {}).get("provider_request_id") or (usage or {}).get("request_id") + try: + attempt.http_status = int((usage or {}).get("http_status")) if (usage or {}).get("http_status") is not None else None + except (TypeError, ValueError): + attempt.http_status = None + # 支持把此前因审计异常误记的 failed 调用恢复为真实 succeeded。 + attempt.error_message = None + attempt.token_unavailable_reason = None + + execution_result = await db.execute( + select(LlmBillingExecution) + .where(LlmBillingExecution.id == attempt.billing_execution_id) + .limit(1) + .with_for_update() + ) + execution = execution_result.scalar_one() + if previous_status in {LlmCallAttemptStatus.FAILED.value, LlmCallAttemptStatus.TIMEOUT.value}: + execution.failed_call_count = max(0, int(execution.failed_call_count or 0) - 1) + execution.successful_call_count += 1 + execution.total_input_tokens += input_tokens + execution.total_output_tokens += output_tokens + execution.total_tokens += total_tokens + + record_result = await db.execute( + select(CreditRecord) + .where(CreditRecord.id == execution.credit_record_id) + .limit(1) + .with_for_update() + ) + record = record_result.scalar_one_or_none() + if record: + record.input_tokens = execution.total_input_tokens + record.output_tokens = execution.total_output_tokens + record.total_tokens = execution.total_tokens + record.llm_call_count = execution.total_call_count + record.llm_success_call_count = execution.successful_call_count + record.llm_failed_call_count = execution.failed_call_count + record.token_usage_id = token_usage_id + ctx.token_usage_id = token_usage_id + + +async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str: + if not ctx.current_call_attempt_id: + await create_call_attempt(ctx) + now = utc_now() + async with AsyncSessionLocal() as db: + async with db.begin(): + result = await db.execute( + select(LlmCallAttempt) + .where(LlmCallAttempt.id == ctx.current_call_attempt_id) + .limit(1) + .with_for_update() + ) + attempt = result.scalar_one_or_none() + if attempt is None: + return "missing" + if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value: + # 供应商已成功并已记录 Token;后续解析/校验/落库失败只能记为后处理失败, + # 不能覆盖真实供应商成功事实,也不能重复累计失败调用次数。 + attempt.postprocess_status = "failed" + attempt.postprocess_error = str(error)[:1000] + return "postprocess_failure" + if attempt.status != LlmCallAttemptStatus.STARTED.value: + return "noop" + attempt.status = LlmCallAttemptStatus.TIMEOUT.value if status == "timeout" else LlmCallAttemptStatus.FAILED.value + attempt.response_received_at = now + attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000)) + attempt.error_message = str(error)[:1000] + attempt.token_unavailable_reason = "供应商调用失败,未返回Token使用量" + execution_result = await db.execute( + select(LlmBillingExecution) + .where(LlmBillingExecution.id == attempt.billing_execution_id) + .limit(1) + .with_for_update() + ) + execution = execution_result.scalar_one() + execution.failed_call_count += 1 + record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update()) + record = record_result.scalar_one_or_none() + if record: + record.llm_call_count = execution.total_call_count + record.llm_success_call_count = execution.successful_call_count + record.llm_failed_call_count = execution.failed_call_count + return "provider_failure" diff --git a/video-gen-api/app/services/llm_billing/config.py b/video-gen-api/app/services/llm_billing/config.py index 0426060f..e1e2ca12 100644 --- a/video-gen-api/app/services/llm_billing/config.py +++ b/video-gen-api/app/services/llm_billing/config.py @@ -1,145 +1,52 @@ from __future__ import annotations +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.enums.llm_billing import LlmBillingConfigKey +from app.models.llm_billing.policy import LlmBillingPolicyModel from app.services.llm_billing.context import LlmBillingPolicy -from app.services.system_config_cache import get_system_config_values - -_DEFAULT_HOLD_CREDITS = 5.0 -_FALSE_VALUES = {"0", "false", "no", "off", "disabled"} -_HOLD_CONFIG_KEYS = { - LlmBillingConfigKey.HOLD_DEFAULT.value, - LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, - LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, - LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, - LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value, -} - - -def _parse_bool(value: str | None, *, default: bool = True) -> bool: - if value is None or str(value).strip() == "": - return default - return str(value).strip().lower() not in _FALSE_VALUES - - -def _parse_float(value: str | float | int | None) -> float | None: - try: - if value is None or str(value).strip() == "": - return None - return round(float(value), 2) - except (TypeError, ValueError): - return None - - -def is_llm_hold_config_key(key: str | None) -> bool: - return bool(key and key in _HOLD_CONFIG_KEYS) async def get_llm_billing_policy( db: AsyncSession, *, - config_key: str | None = None, - explicit_hold_credits: float | None = None, - default: float = _DEFAULT_HOLD_CREDITS, + scene_code: str | None = None, ) -> LlmBillingPolicy: - keys = [LlmBillingConfigKey.ENABLED.value] - if config_key: - keys.append(config_key) - keys.extend( - [ - LlmBillingConfigKey.HOLD_DEFAULT.value, - LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value, - ] - ) - # 去重并保持优先级;一次读取避免 enabled/scene/default 分散查询。 - ordered_keys = list(dict.fromkeys(keys)) - values = await get_system_config_values(db, ordered_keys) - enabled = _parse_bool(values.get(LlmBillingConfigKey.ENABLED.value), default=True) - if not enabled: - return LlmBillingPolicy(enabled=False, hold_credits=0.0, config_key=config_key) - - if explicit_hold_credits is not None: - amount = _parse_float(explicit_hold_credits) - source_key = "explicit" - else: - amount = None - source_key = None - for key in ordered_keys[1:]: - parsed = _parse_float(values.get(key)) - if parsed is not None: - amount = parsed - source_key = key - break - if amount is None: - amount = round(float(default), 2) - source_key = "default" - - if amount is None or amount <= 0: + """按业务场景读取固定预扣;不再读取旧 SystemConfig 冻结配置。""" + resolved_scene = scene_code + if not resolved_scene: return LlmBillingPolicy( - enabled=True, - hold_credits=float(amount or 0), - config_key=config_key, - source_key=source_key, + pre_deduct_credits=0.0, valid=False, - error="启用LLM统一计费时,预扣积分必须大于0", + error="LLM业务场景不能为空", + ) + result = await db.execute( + select(LlmBillingPolicyModel) + .where(LlmBillingPolicyModel.scene_code == resolved_scene) + .limit(1) + ) + model = result.scalar_one_or_none() + if model is None: + return LlmBillingPolicy( + pre_deduct_credits=0.0, + valid=False, + error=f"未配置LLM场景固定预扣积分:{resolved_scene}", + ) + amount = float(model.pre_deduct_credits) + if not model.is_active or amount <= 0: + return LlmBillingPolicy( + pre_deduct_credits=amount, + valid=False, + error=f"LLM场景固定预扣配置未启用或金额无效:{resolved_scene}", + policy_id=model.id, + version=model.version, + scene_name=model.scene_name, ) return LlmBillingPolicy( - enabled=True, - hold_credits=round(float(amount), 2), - config_key=config_key, - source_key=source_key, + pre_deduct_credits=round(amount, 2), + valid=True, + policy_id=model.id, + version=model.version, + scene_name=model.scene_name, ) - -async def get_llm_hold_credits( - db: AsyncSession, - *, - config_key: str | None = None, - default: float = _DEFAULT_HOLD_CREDITS, -) -> float: - policy = await get_llm_billing_policy(db, config_key=config_key, default=default) - return policy.hold_credits - - -async def is_llm_billing_enabled(db: AsyncSession) -> bool: - return (await get_llm_billing_policy(db)).enabled - - -async def validate_llm_system_config_value( - db: AsyncSession, - *, - key: str, - value: str, -) -> None: - """校验后台单项更新,避免启用计费时保存零或负数预扣。""" - if key == LlmBillingConfigKey.ENABLED.value: - if not _parse_bool(value, default=True): - return - keys = [ - LlmBillingConfigKey.HOLD_DEFAULT.value, - LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, - LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, - LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, - LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - ] - values = await get_system_config_values(db, keys, ttl_seconds=1) - invalid = [ - config_name - for config_name in keys - if (raw_value := values.get(config_name)) is not None - and str(raw_value).strip() != "" - and ((parsed := _parse_float(raw_value)) is None or parsed <= 0) - ] - if invalid: - raise ValueError(f"启用LLM统一计费前,请先将以下预扣配置设置为大于0:{', '.join(invalid)}") - return - - if not is_llm_hold_config_key(key): - return - parsed = _parse_float(value) - enabled_values = await get_system_config_values(db, [LlmBillingConfigKey.ENABLED.value], ttl_seconds=1) - enabled = _parse_bool(enabled_values.get(LlmBillingConfigKey.ENABLED.value), default=True) - if enabled and (parsed is None or parsed <= 0): - raise ValueError("启用LLM统一计费时,预扣积分必须大于0") diff --git a/video-gen-api/app/services/llm_billing/context.py b/video-gen-api/app/services/llm_billing/context.py index d94f0952..2bcbf63e 100644 --- a/video-gen-api/app/services/llm_billing/context.py +++ b/video-gen-api/app/services/llm_billing/context.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from app.enums.credit_record import CreditRecordChargeKind from app.enums.llm_billing import LlmBillingLedgerState @@ -8,25 +9,29 @@ from app.services.generation.billing_service import build_credit_biz_key class LlmBillingConfigurationError(RuntimeError): - """LLM 统一账务配置无效,必须在调用模型前终止。""" + """LLM 场景固定预扣配置无效。""" class LlmBillingStateError(RuntimeError): - """当前 attempt 的账务流水状态不允许继续执行。""" + """业务 attempt 的固定预扣状态不允许继续执行。""" + + +class LlmProviderPostprocessError(RuntimeError): + """供应商请求已成功并返回用量,但响应内容或业务结构处理失败。""" + + def __init__(self, message: str, *, usage: dict | None = None) -> None: + super().__init__(message) + self.usage = dict(usage or {}) @dataclass(slots=True, frozen=True) class LlmBillingPolicy: - enabled: bool - hold_credits: float - config_key: str | None = None - source_key: str | None = None + pre_deduct_credits: float valid: bool = True error: str | None = None - - @property - def bypassed(self) -> bool: - return not self.enabled + policy_id: str | None = None + version: int | None = None + scene_name: str | None = None @dataclass(slots=True) @@ -42,72 +47,63 @@ class LlmBillingContext: source_step_id: str | None = None source_step_code: str | None = None related_id: str | None = None - hold_credits: float | None = None - hold_config_key: str | None = None description_prefix: str = "LLM" trace_id: str | None = None request_id: str | None = None celery_task_id: str | None = None provider: str | None = None model_name: str | None = None + model_config_id: str | None = None + model_parameters_snapshot: dict | None = None token_usage_id: str | None = None + # 供应商响应成功事实先于审计落库设置。即使审计事务暂时失败, + # 后续异常处理也不能把真实供应商成功覆盖成 provider failed。 + provider_call_succeeded: bool = False + provider_usage_snapshot: dict | None = None + request_time: datetime | None = None + billing_execution_id: str | None = None + current_call_attempt_id: str | None = None @property - def hold_biz_key(self) -> str: + def pre_deduct_biz_key(self) -> str: return build_credit_biz_key( owner_type=self.owner_type, owner_id=self.owner_id, attempt_no=self.attempt_no, charge_kind=self.charge_kind, - action="hold", + action="pre_deduct", ) @property - def hold_release_biz_key(self) -> str: + def refund_biz_key(self) -> str: return build_credit_biz_key( owner_type=self.owner_type, owner_id=self.owner_id, attempt_no=self.attempt_no, charge_kind=self.charge_kind, - action="hold_release", + action="refund", ) @property - def charge_biz_key(self) -> str: - return build_credit_biz_key( - owner_type=self.owner_type, - owner_id=self.owner_id, - attempt_no=self.attempt_no, - charge_kind=self.charge_kind, - action="charge", - ) - - @property - def ledger_biz_keys(self) -> tuple[str, str, str]: - return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key + def billing_biz_key(self) -> str: + return self.pre_deduct_biz_key @dataclass(slots=True, frozen=True) -class LlmHoldResult: +class LlmPreDeductResult: amount: float state: LlmBillingLedgerState created: bool = False record_id: str | None = None reason: str | None = None - - @property - def bypassed(self) -> bool: - return self.state == LlmBillingLedgerState.BILLING_BYPASSED + execution_id: str | None = None @dataclass(slots=True, frozen=True) -class LlmHoldValidation: +class LlmPreDeductValidation: can_execute: bool amount: float state: LlmBillingLedgerState reason: str | None = None - hold_record_id: str | None = None - - @property - def bypassed(self) -> bool: - return self.state == LlmBillingLedgerState.BILLING_BYPASSED + pre_deduct_record_id: str | None = None + execution_id: str | None = None diff --git a/video-gen-api/app/services/llm_billing/query_service.py b/video-gen-api/app/services/llm_billing/query_service.py new file mode 100644 index 00000000..cdfadddf --- /dev/null +++ b/video-gen-api/app/services/llm_billing/query_service.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.llm_billing.call_attempt import LlmCallAttempt +from app.models.llm_billing.execution import LlmBillingExecution + + +async def list_executions_with_calls( + db: AsyncSession, + *, + page: int = 1, + page_size: int = 20, + scene_code: str | None = None, + status: str | None = None, + user_id: str | None = None, +) -> tuple[list[dict], int]: + filters = [] + if scene_code: + filters.append(LlmBillingExecution.scene_code == scene_code) + if status: + filters.append(LlmBillingExecution.status == status) + if user_id: + filters.append(LlmBillingExecution.user_id == user_id) + total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters)) + total = int(total_result.scalar_one() or 0) + result = await db.execute( + select(LlmBillingExecution) + .where(*filters) + .order_by(LlmBillingExecution.created_at.desc(), LlmBillingExecution.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + executions = list(result.scalars().all()) + ids = [item.id for item in executions] + calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in ids} + if ids: + call_result = await db.execute( + select(LlmCallAttempt) + .where(LlmCallAttempt.billing_execution_id.in_(ids)) + .order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence) + ) + for call in call_result.scalars().all(): + calls_by_execution.setdefault(call.billing_execution_id, []).append(call) + items = [] + for execution in executions: + data = { + column.name: getattr(execution, column.name) + for column in LlmBillingExecution.__table__.columns + } + data["calls"] = calls_by_execution.get(execution.id, []) + items.append(data) + return items, total diff --git a/video-gen-api/app/services/llm_billing/service.py b/video-gen-api/app/services/llm_billing/service.py index 21860a2f..2d24a88c 100644 --- a/video-gen-api/app/services/llm_billing/service.py +++ b/video-gen-api/app/services/llm_billing/service.py @@ -1,169 +1,377 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Any, Iterable, Mapping -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.credit_record import ( + CREDIT_RECORD_BILLING_SCENE_LABELS, CreditRecordAction, - CreditRecordBillingScene, - CreditRecordChargeKind, - CreditRecordOwnerType, - CreditRecordSourceModule, CreditRecordSubject, ) -from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingLedgerState -from app.models.credit_record import CreditRecord -from app.models.module_generation_step import ModuleGenerationStep -from app.models.token_usage import TokenUsage -from app.services.credit_record_meta_service import ( - CreditRecordMeta, - build_generation_record_prompt_meta, - build_module_step_prompt_meta, - build_shot_video_analysis_meta, +from app.enums.llm_billing import ( + LlmBillingDomain, + LlmBillingEvent, + LlmBillingExecutionStatus, + LlmBillingLedgerState, ) -from app.services.credits import add_credits_result, calc_text_credits, deduct_credits_result +from app.models.base import AsyncSessionLocal +from app.models.credit_record import CreditRecord +from app.models.llm_billing.call_attempt import LlmCallAttempt +from app.models.llm_billing.execution import LlmBillingExecution +from app.models.model_config import ModelConfig +from app.services.credit.locking import acquire_user_credit_lock +from app.services.credit.utils import to_float, utc_now +from app.services.credits import deduct_credits_result from app.services.generation.billing_service import BillingItem, BillingSummary +from app.services.llm_billing.call_audit_service import ( + create_call_attempt, + finish_call_failure, + finish_call_success, +) from app.services.llm_billing.config import get_llm_billing_policy from app.services.llm_billing.context import ( LlmBillingConfigurationError, LlmBillingContext, LlmBillingStateError, - LlmHoldResult, - LlmHoldValidation, + LlmProviderPostprocessError, + LlmPreDeductResult, + LlmPreDeductValidation, ) from app.services.operation_log_service import log_operation_event -from app.utils.exceptions import InsufficientCreditsError from app.utils.id_gen import generate_id -_LEDGER_QUERY_BATCH_SIZE = 1000 - @dataclass(slots=True) -class _LedgerRecords: +class _ExecutionState: + execution: LlmBillingExecution | None state: LlmBillingLedgerState - hold: CreditRecord | None = None - release: CreditRecord | None = None - charge: CreditRecord | None = None reason: str | None = None - @property - def hold_amount(self) -> float: - return _round2(abs(float(self.hold.amount or 0))) if self.hold else 0.0 + +def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str: + return ( + policy_name + or CREDIT_RECORD_BILLING_SCENE_LABELS.get(ctx.billing_scene or "") + or ctx.description_prefix + or ctx.billing_scene + or "LLM功能" + ) - -def _round2(value: Any) -> float: - try: - return round(float(value or 0), 2) - except (TypeError, ValueError): - return 0.0 - - - -def _safe_int(value: Any, default: int = 0) -> int: - try: - if value is None or value == "": - return default - return int(value) - except (TypeError, ValueError): - return default - - - -def _context_detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]: - detail = { - "user_id": ctx.user_id, +def _detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]: + return { + "scene_code": ctx.billing_scene, "owner_type": ctx.owner_type, "owner_id": ctx.owner_id, "attempt_no": ctx.attempt_no, - "charge_kind": ctx.charge_kind, - "billing_scene": ctx.billing_scene, - "source_module": ctx.source_module, - "source_project_id": ctx.source_project_id, - "source_step_id": ctx.source_step_id, - "source_step_code": ctx.source_step_code, - "related_id": ctx.related_id, - "hold_biz_key": ctx.hold_biz_key, - "hold_release_biz_key": ctx.hold_release_biz_key, - "charge_biz_key": ctx.charge_biz_key, - "celery_task_id": ctx.celery_task_id, - "provider": ctx.provider, + "billing_execution_id": ctx.billing_execution_id, + "model_config_id": ctx.model_config_id, "model_name": ctx.model_name, - "token_usage_id": ctx.token_usage_id, + "provider": ctx.provider, + **extra, } - detail.update({key: value for key, value in extra.items() if value is not None}) - return {key: value for key, value in detail.items() if value is not None} - -def _log( - ctx: LlmBillingContext, - event: LlmBillingEvent, - *, - status: str = "success", - message: str | None = None, - detail: dict[str, Any] | None = None, - error: str | None = None, -) -> None: +def _log(ctx: LlmBillingContext, event: LlmBillingEvent, *, status: str = "success", error: str | None = None, **detail: Any) -> None: log_operation_event( domain=LlmBillingDomain.LLM_BILLING.value, - module=ctx.source_module or LlmBillingDomain.LLM_BILLING.value, + module=ctx.source_module or "llm", event_type=event.value, event_status=status, - source="app.services.llm_billing.service", trace_id=ctx.trace_id, request_id=ctx.request_id, user_id=ctx.user_id, project_id=ctx.source_project_id, - task_id=ctx.owner_id, + task_id=ctx.celery_task_id, step_id=ctx.source_step_id, - message=message, - detail=detail or _context_detail(ctx), + message=_scene_name(ctx), + detail=_detail(ctx, **detail), error=error, ) +async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None: + if ctx.model_config_id: + result = await db.execute(select(ModelConfig).where(ModelConfig.id == ctx.model_config_id).limit(1)) + model = result.scalar_one_or_none() + else: + result = await db.execute( + select(ModelConfig) + .where(ModelConfig.is_active.is_(True), ModelConfig.deleted_at.is_(None), ModelConfig.provider != "mock") + .order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()) + .limit(1) + ) + model = result.scalar_one_or_none() + if model is None: + raise LlmBillingConfigurationError("没有可用的真实LLM模型,且本版本禁止Mock或自动降级") + ctx.model_config_id = model.id + ctx.model_name = model.model_name + ctx.provider = model.provider + ctx.model_parameters_snapshot = { + "model_config_id": model.id, + "name": model.name, + "provider": model.provider, + "api_base": model.api_base, + "model_name": model.model_name, + "max_tokens": model.max_tokens, + "temperature": model.temperature, + } -def log_provider_start(ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None: - _log( - ctx, - LlmBillingEvent.PROVIDER_START, - status="started", - detail=_context_detail(ctx, **dict(detail or {})), + +async def _find_execution(db: AsyncSession, ctx: LlmBillingContext, *, for_update: bool = False) -> LlmBillingExecution | None: + stmt = select(LlmBillingExecution).where( + LlmBillingExecution.user_id == ctx.user_id, + LlmBillingExecution.scene_code == (ctx.billing_scene or ""), + LlmBillingExecution.owner_type == ctx.owner_type, + LlmBillingExecution.owner_id == ctx.owner_id, + LlmBillingExecution.business_attempt_no == ctx.attempt_no, + ).limit(1) + if for_update: + stmt = stmt.with_for_update() + result = await db.execute(stmt) + return result.scalar_one_or_none() + + +def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLedgerState: + if execution is None: + return LlmBillingLedgerState.MISSING + mapping = { + LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE, + LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE, + LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED, + LlmBillingExecutionStatus.FINAL_FAILED.value: LlmBillingLedgerState.FINAL_FAILED, + LlmBillingExecutionStatus.REFUNDED.value: LlmBillingLedgerState.REFUNDED, + LlmBillingExecutionStatus.REFUND_FAILED.value: LlmBillingLedgerState.INVALID, + } + return mapping.get(execution.status, LlmBillingLedgerState.INVALID) + + +async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductResult: + """在业务 API 事务内同步足额预扣固定场景积分。""" + _log(ctx, LlmBillingEvent.PRE_DEDUCT_START, status="started") + if not ctx.billing_scene: + raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene") + # 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求 + # 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。 + await acquire_user_credit_lock(db, ctx.user_id) + existing = await _find_execution(db, ctx, for_update=True) + if existing: + ctx.billing_execution_id = existing.id + ctx.model_config_id = existing.model_config_id + ctx.model_name = existing.model_name_snapshot + ctx.provider = existing.provider_snapshot + return LlmPreDeductResult( + amount=to_float(existing.pre_deduct_credits), + state=_state_for_execution(existing), + created=False, + record_id=existing.credit_record_id, + execution_id=existing.id, + ) + + await _select_fixed_model(db, ctx) + policy = await get_llm_billing_policy( + db, + scene_code=ctx.billing_scene, + ) + if not policy.valid: + _log(ctx, LlmBillingEvent.PRE_DEDUCT_CONFIG_INVALID, status="failed", error=policy.error) + raise LlmBillingConfigurationError(policy.error or "LLM固定预扣配置无效") + + scene_name = _scene_name(ctx, policy.scene_name) + meta = { + "owner_type": ctx.owner_type, + "owner_id": ctx.owner_id, + "attempt_no": ctx.attempt_no, + "charge_kind": ctx.charge_kind, + "charge_action": CreditRecordAction.PRE_DEDUCT.value, + "credit_subject": CreditRecordSubject.TEXT.value, + "billing_scene": ctx.billing_scene, + "scene_name_snapshot": scene_name, + "source_module": ctx.source_module, + "source_project_id": ctx.source_project_id, + "source_step_id": ctx.source_step_id, + "source_step_code": ctx.source_step_code, + "engine_type": "model", + "engine_id": ctx.model_config_id, + "engine_provider": ctx.provider, + "engine_model_name": ctx.model_name, + } + mutation = await deduct_credits_result( + db, + user_id=ctx.user_id, + amount=policy.pre_deduct_credits, + description=f"{scene_name}固定预扣积分", + related_id=ctx.related_id or ctx.owner_id, + biz_key=ctx.pre_deduct_biz_key, + record_meta=meta, + record_type="consume", + request_time=ctx.request_time or utc_now(), + ) + if mutation.record is None: + raise LlmBillingStateError("LLM固定预扣流水创建失败") + execution = LlmBillingExecution( + id=generate_id(), + user_id=ctx.user_id, + scene_code=ctx.billing_scene, + scene_name_snapshot=scene_name, + owner_type=ctx.owner_type, + owner_id=ctx.owner_id, + business_attempt_no=ctx.attempt_no, + model_config_id=ctx.model_config_id, + model_name_snapshot=ctx.model_name, + provider_snapshot=ctx.provider, + model_parameters_snapshot=ctx.model_parameters_snapshot, + billing_policy_id=policy.policy_id, + billing_policy_version=policy.version, + request_time=ctx.request_time or utc_now(), + pre_deduct_credits=policy.pre_deduct_credits, + credit_record_id=mutation.record.id, + status=LlmBillingExecutionStatus.PRE_DEDUCTED.value, + ) + db.add(execution) + await db.flush() + mutation.record.llm_billing_execution_id = execution.id + mutation.record.scene_name_snapshot = scene_name + ctx.billing_execution_id = execution.id + _log(ctx, LlmBillingEvent.PRE_DEDUCT_SUCCESS, amount=policy.pre_deduct_credits, credit_record_id=mutation.record.id) + return LlmPreDeductResult( + amount=policy.pre_deduct_credits, + state=LlmBillingLedgerState.ACTIVE, + created=mutation.created, + record_id=mutation.record.id, + execution_id=execution.id, ) -def log_provider_success(ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None: - usage_snapshot = dict(usage or {}) - ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider - ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name - ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id - _log( - ctx, - LlmBillingEvent.PROVIDER_SUCCESS, - detail=_context_detail( - ctx, - input_tokens=_safe_int(usage_snapshot.get("input_tokens")), - output_tokens=_safe_int(usage_snapshot.get("output_tokens")), - total_tokens=_safe_int(usage_snapshot.get("total_tokens")), - ), +async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation: + execution = await _find_execution(db, ctx) + if execution is None: + return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing") + ctx.billing_execution_id = execution.id + ctx.model_config_id = execution.model_config_id + ctx.model_name = execution.model_name_snapshot + ctx.provider = execution.provider_snapshot + ctx.model_parameters_snapshot = execution.model_parameters_snapshot + state = _state_for_execution(execution) + can_execute = state == LlmBillingLedgerState.ACTIVE + if not can_execute: + _log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status) + else: + _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status) + return LlmPreDeductValidation( + can_execute=can_execute, + amount=to_float(execution.pre_deduct_credits), + state=state, + reason=None if can_execute else execution.status, + pre_deduct_record_id=execution.credit_record_id, + execution_id=execution.id, ) -def log_provider_failure(ctx: LlmBillingContext, *, error: str) -> None: +async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]: + items = list(contexts) + if not items: + return {} + conditions = [ + and_( + LlmBillingExecution.user_id == ctx.user_id, + LlmBillingExecution.scene_code == (ctx.billing_scene or ""), + LlmBillingExecution.owner_type == ctx.owner_type, + LlmBillingExecution.owner_id == ctx.owner_id, + LlmBillingExecution.business_attempt_no == ctx.attempt_no, + ) + for ctx in items + ] + result = await db.execute(select(LlmBillingExecution).where(or_(*conditions))) + by_key = { + (row.user_id, row.scene_code, row.owner_type, row.owner_id, row.business_attempt_no): row + for row in result.scalars().all() + } + output: dict[str, LlmPreDeductValidation] = {} + for ctx in items: + row = by_key.get((ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no)) + state = _state_for_execution(row) + output[ctx.pre_deduct_biz_key] = LlmPreDeductValidation( + can_execute=state == LlmBillingLedgerState.ACTIVE, + amount=to_float(row.pre_deduct_credits) if row else 0.0, + state=state, + reason=None if state == LlmBillingLedgerState.ACTIVE else (row.status if row else "missing"), + pre_deduct_record_id=row.credit_record_id if row else None, + execution_id=row.id if row else None, + ) + return output + + +async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation: + previous = replace(ctx, attempt_no=max(1, ctx.attempt_no - 1)) if ctx.attempt_no > 1 else ctx + validation = (await get_llm_ledger_states(db, [previous]))[previous.pre_deduct_biz_key] + if ctx.attempt_no <= 1: + return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt") + allowed = validation.state in { + LlmBillingLedgerState.SUCCEEDED, + LlmBillingLedgerState.REFUNDED, + LlmBillingLedgerState.FINAL_FAILED, + } + return LlmPreDeductValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.pre_deduct_record_id, validation.execution_id) + + +async def log_provider_start(db: AsyncSession, ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None: + if not ctx.billing_execution_id: + validation = await ensure_pre_deducted(db, ctx) + if not validation.can_execute: + raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM") + await create_call_attempt(ctx, detail=detail) + _log(ctx, LlmBillingEvent.PROVIDER_START, status="started", call_attempt_id=ctx.current_call_attempt_id, detail=dict(detail or {})) + + +async def log_provider_success(db: AsyncSession, ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None: + # 先记录内存中的供应商成功事实。即使独立审计事务异常,外层异常处理也 + # 必须按“供应商成功、后处理失败”处理,不能覆盖成 provider failed。 + ctx.provider_call_succeeded = True + ctx.provider_usage_snapshot = dict(usage or {}) + await finish_call_success(ctx, usage=usage) + _log(ctx, LlmBillingEvent.PROVIDER_SUCCESS, call_attempt_id=ctx.current_call_attempt_id, usage=dict(usage or {})) + + +async def log_provider_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str) -> None: + if ctx.provider_call_succeeded: + # 可能是首次成功审计落库失败后进入外层异常处理。先幂等补记真实成功, + # 再把当前异常记为 postprocess failure。 + await finish_call_success(ctx, usage=ctx.provider_usage_snapshot or {}) + outcome = await finish_call_failure(ctx, error=error) + event = LlmBillingEvent.POSTPROCESS_FAILURE if outcome == "postprocess_failure" else LlmBillingEvent.PROVIDER_FAILURE _log( ctx, - LlmBillingEvent.PROVIDER_FAILURE, + event, status="failed", - detail=_context_detail(ctx, error_type="provider_call_failed"), + call_attempt_id=ctx.current_call_attempt_id, + failure_stage="postprocess" if outcome == "postprocess_failure" else "provider", error=error, ) +async def record_provider_exception( + db: AsyncSession, + ctx: LlmBillingContext, + exc: Exception, +) -> tuple[bool, dict[str, Any]]: + """记录供应商失败,或“供应商成功但后处理失败”的真实调用事实。""" + if ctx.provider_call_succeeded: + usage = dict(ctx.provider_usage_snapshot or {}) + await log_provider_failure(db, ctx, error=str(exc)) + return True, usage + if isinstance(exc, LlmProviderPostprocessError): + usage = dict(exc.usage or {}) + await log_provider_success(db, ctx, usage=usage) + await log_provider_failure(db, ctx, error=str(exc)) + return True, usage + await log_provider_failure(db, ctx, error=str(exc)) + return False, {} + + def log_celery_dispatch_start(ctx: LlmBillingContext) -> None: _log(ctx, LlmBillingEvent.CELERY_DISPATCH_START, status="started") @@ -177,976 +385,157 @@ def log_celery_dispatch_failure(ctx: LlmBillingContext, *, error: str) -> None: def log_celery_dispatch_compensated(ctx: LlmBillingContext, *, error: str) -> None: - _log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, detail=_context_detail(ctx, compensation_error=error)) + _log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, status="warning", error=error) -def _hold_meta(ctx: LlmBillingContext, *, action: str) -> CreditRecordMeta: - subject = ( - CreditRecordSubject.ANALYSIS.value - if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value - else CreditRecordSubject.TEXT.value - ) - return CreditRecordMeta( - owner_type=ctx.owner_type, - owner_id=ctx.owner_id, - attempt_no=ctx.attempt_no, - charge_kind=ctx.charge_kind, - charge_action=action, - credit_subject=subject, - media_type="video" if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value else None, - billing_scene=ctx.billing_scene, - source_module=ctx.source_module, - source_project_id=ctx.source_project_id, - source_step_id=ctx.source_step_id, - source_step_code=ctx.source_step_code, - ) - - - -def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) -> bool: - if record is None: - return True - # 兼容旧数据:正式 biz_key 已明确动作、charge_action 为空时仍可识别。 - return record.charge_action in (None, expected.value) - - - -def _record_meta_matches_context(record: CreditRecord | None, ctx: LlmBillingContext) -> bool: - if record is None: - return True - checks = ( - (record.owner_type, ctx.owner_type), - (record.owner_id, ctx.owner_id), - (record.attempt_no, ctx.attempt_no), - (record.charge_kind, ctx.charge_kind), - ) - return all(actual is None or str(actual) == str(expected) for actual, expected in checks) - - -def _classify_ledger( - *, - ctx: LlmBillingContext, - hold: CreditRecord | None, - release: CreditRecord | None, - charge: CreditRecord | None, -) -> _LedgerRecords: - if not _action_valid(hold, CreditRecordAction.HOLD): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_action_mismatch") - if not _action_valid(release, CreditRecordAction.HOLD_RELEASE): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch") - if not _action_valid(charge, CreditRecordAction.CHARGE): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch") - if not _record_meta_matches_context(hold, ctx): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_context_mismatch") - if not _record_meta_matches_context(release, ctx): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_context_mismatch") - if not _record_meta_matches_context(charge, ctx): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_context_mismatch") - if hold is None: - if release is not None or charge is not None: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup") - return _LedgerRecords(LlmBillingLedgerState.MISSING) - if release is None and charge is None: - if hold.type != "consume" or _round2(float(hold.amount or 0)) >= 0: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_type_or_sign_invalid") - if _round2(abs(float(hold.amount or 0))) <= 0: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive") - return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold) - if release is not None and charge is None: - if release.type != "refund" or _round2(float(release.amount or 0)) <= 0: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid") - if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch") - if _round2(release.amount) != _round2(abs(float(hold.amount or 0))): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch") - return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release) - if release is not None and charge is not None: - if release.type != "refund" or _round2(float(release.amount or 0)) <= 0: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid") - if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch") - if _round2(release.amount) != _round2(abs(float(hold.amount or 0))): - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch") - if charge.type != "consume" or _round2(float(charge.amount or 0)) > 0: - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_type_or_sign_invalid") - return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge) - return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release") - - -async def _load_ledgers( - db: AsyncSession, - contexts: Iterable[LlmBillingContext], -) -> dict[str, _LedgerRecords]: - context_list = list(contexts) - if not context_list: - return {} - record_map: dict[tuple[str, str], CreditRecord] = {} - all_keys = list(dict.fromkeys(key for ctx in context_list for key in ctx.ledger_biz_keys)) - user_ids = list(dict.fromkeys(ctx.user_id for ctx in context_list)) - for offset in range(0, len(all_keys), _LEDGER_QUERY_BATCH_SIZE): - chunk = all_keys[offset : offset + _LEDGER_QUERY_BATCH_SIZE] - result = await db.execute( - select(CreditRecord).where( - CreditRecord.user_id.in_(user_ids), - CreditRecord.biz_key.in_(chunk), - ) - ) - for record in result.scalars().all(): - if record.biz_key: - record_map[(str(record.user_id), str(record.biz_key))] = record - - output: dict[str, _LedgerRecords] = {} - for ctx in context_list: - hold = record_map.get((ctx.user_id, ctx.hold_biz_key)) - release = record_map.get((ctx.user_id, ctx.hold_release_biz_key)) - charge = record_map.get((ctx.user_id, ctx.charge_biz_key)) - output[ctx.hold_biz_key] = _classify_ledger(ctx=ctx, hold=hold, release=release, charge=charge) - return output - - -async def _load_ledger(db: AsyncSession, ctx: LlmBillingContext) -> _LedgerRecords: - return (await _load_ledgers(db, [ctx]))[ctx.hold_biz_key] - - -async def get_llm_ledger_states( - db: AsyncSession, - contexts: Iterable[LlmBillingContext], -) -> dict[str, LlmHoldValidation]: - """批量读取 attempt 的三类流水;供恢复任务收集 ID 后统一过滤。""" - context_list = list(contexts) - ledgers = await _load_ledgers(db, context_list) - return { - ctx.hold_biz_key: LlmHoldValidation( - can_execute=ledgers[ctx.hold_biz_key].state == LlmBillingLedgerState.ACTIVE, - amount=ledgers[ctx.hold_biz_key].hold_amount, - state=ledgers[ctx.hold_biz_key].state, - reason=ledgers[ctx.hold_biz_key].reason, - hold_record_id=ledgers[ctx.hold_biz_key].hold.id if ledgers[ctx.hold_biz_key].hold else None, - ) - for ctx in context_list - } - - -async def validate_retryable_previous_attempt( - db: AsyncSession, - ctx: LlmBillingContext, -) -> LlmHoldValidation: - """校验失败 attempt 的账务是否已关闭,供业务创建下一 attempt 前调用。 - - 只有已释放 HOLD,或当前计费明确关闭且旧 attempt 没有流水时,才允许创建 - 新 attempt。这里不主动退款,避免重试入口承担失败补偿职责。 - """ - _log( - ctx, - LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_START, - status="started", - ) - ledger = await _load_ledger(db, ctx) - - if ledger.state == LlmBillingLedgerState.RELEASED: - result = LlmHoldValidation( - True, - ledger.hold_amount, - ledger.state, - hold_record_id=ledger.hold.id if ledger.hold else None, - ) - _log( - ctx, - LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS, - detail=_context_detail( - ctx, - ledger_state=result.state.value, - hold_credits=result.amount, - hold_record_id=result.hold_record_id, - ), - ) - return result - - if ledger.state == LlmBillingLedgerState.MISSING: - policy = await get_llm_billing_policy( - db, - config_key=ctx.hold_config_key, - explicit_hold_credits=None, - ) - if policy.bypassed: - result = LlmHoldValidation( - True, - 0.0, - LlmBillingLedgerState.BILLING_BYPASSED, - "billing_disabled", - ) - _log( - ctx, - LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS, - status="skipped", - detail=_context_detail( - ctx, - ledger_state=result.state.value, - billing_bypassed=True, - ), - ) - return result - if not policy.valid: - result = LlmHoldValidation( - False, - 0.0, - LlmBillingLedgerState.INVALID, - policy.error or "billing_config_invalid", - ) - else: - result = LlmHoldValidation( - False, - 0.0, - ledger.state, - "previous_attempt_hold_missing", - ) - else: - result = LlmHoldValidation( - False, - ledger.hold_amount, - ledger.state, - ledger.reason or f"previous_attempt_ledger_{ledger.state.value}", - ledger.hold.id if ledger.hold else None, - ) - - _log( - ctx, - LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_BLOCKED, - status="failed", - detail=_context_detail( - ctx, - ledger_state=result.state.value, - hold_credits=result.amount, - hold_record_id=result.hold_record_id, - skip_reason=result.reason, - ), - error="旧attempt账务尚未关闭,拒绝创建新的分析attempt", - ) - return result - - -async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult: - # 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。 - ledger = await _load_ledger(db, ctx) - - # 配置可能在任务执行期间被关闭或修改:已经存在的 active HOLD 必须继续沿用, - # 否则会留下永久冻结流水。只有“没有任何历史流水”的新 attempt 才允许按关闭配置绕过。 - if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: - amount = ledger.hold_amount - ctx.hold_credits = amount - _log( - ctx, - LlmBillingEvent.HOLD_SUCCESS, - detail=_context_detail( - ctx, - hold_credits=amount, - hold_record_id=ledger.hold.id, - idempotent=True, - ledger_state=ledger.state.value, - ), - ) - return LlmHoldResult(amount, ledger.state, created=False, record_id=ledger.hold.id) - - if ledger.state != LlmBillingLedgerState.MISSING: - error = f"当前attempt账务状态为{ledger.state.value},不能复用旧预扣" - _log( - ctx, - LlmBillingEvent.EXECUTION_BLOCKED, - status="failed", - detail=_context_detail(ctx, ledger_state=ledger.state.value, reason=ledger.reason), - error=error, - ) - raise LlmBillingStateError(error) - - policy = await get_llm_billing_policy( - db, - config_key=ctx.hold_config_key, - explicit_hold_credits=ctx.hold_credits, - ) - if policy.bypassed: - _log( - ctx, - LlmBillingEvent.HOLD_BYPASSED, - status="skipped", - detail=_context_detail(ctx, ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value), - ) - return LlmHoldResult( - 0.0, - LlmBillingLedgerState.BILLING_BYPASSED, - reason="billing_disabled", - ) - if not policy.valid: - _log( - ctx, - LlmBillingEvent.HOLD_CONFIG_INVALID, - status="failed", - detail=_context_detail( - ctx, - hold_credits=policy.hold_credits, - config_key=policy.config_key, - config_source=policy.source_key, - ), - error=policy.error, - ) - raise LlmBillingConfigurationError(policy.error or "LLM计费配置无效") - - amount = policy.hold_credits - ctx.hold_credits = amount - _log( - ctx, - LlmBillingEvent.HOLD_START, - status="started", - detail=_context_detail( - ctx, - hold_credits=amount, - config_key=policy.config_key, - config_source=policy.source_key, - ), - ) - try: - mutation = await deduct_credits_result( - db, - user_id=ctx.user_id, - amount=amount, - description=f"{ctx.description_prefix}预扣积分", - related_id=ctx.related_id or ctx.owner_id, - biz_key=ctx.hold_biz_key, - record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD.value), - allow_negative=False, - ) - except InsufficientCreditsError: - _log( - ctx, - LlmBillingEvent.HOLD_INSUFFICIENT, - status="failed", - detail=_context_detail(ctx, hold_credits=amount), - error="积分不足,无法预扣", - ) - raise - - if not mutation.created: - # 并发幂等命中后重新读取三类流水,避免复用已被另一事务释放的 HOLD。 - ledger = await _load_ledger(db, ctx) - if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None: - error = f"并发预扣后账务状态为{ledger.state.value},拒绝继续执行" - _log( - ctx, - LlmBillingEvent.EXECUTION_BLOCKED, - status="failed", - detail=_context_detail(ctx, ledger_state=ledger.state.value), - error=error, - ) - raise LlmBillingStateError(error) - mutation_record = ledger.hold - amount = ledger.hold_amount - else: - mutation_record = mutation.record - - _log( - ctx, - LlmBillingEvent.HOLD_SUCCESS, - detail=_context_detail( - ctx, - hold_credits=amount, - hold_record_id=mutation_record.id if mutation_record else None, - idempotent=not mutation.created, - balance_before=mutation.balance_before, - balance_after=mutation.balance_after, - ledger_state=LlmBillingLedgerState.ACTIVE.value, - ), - ) - return LlmHoldResult( - amount, - LlmBillingLedgerState.ACTIVE, - created=mutation.created, - record_id=mutation_record.id if mutation_record else None, - ) - - -async def ensure_hold_exists(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldValidation: - """worker 调用模型前确认计费绕过或 active HOLD;不在 worker 首次预扣。""" - _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started") - ledger = await _load_ledger(db, ctx) - - # 先尊重已经落库的 attempt 账务状态,再处理当前配置。这样关闭计费不会 - # 把运行中的 active HOLD 遗留为永久冻结;而新建且没有 HOLD 的任务才会绕过。 - if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: - amount = ledger.hold_amount - ctx.hold_credits = amount - result = LlmHoldValidation(True, amount, ledger.state, hold_record_id=ledger.hold.id) - _log( - ctx, - LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, - detail=_context_detail( - ctx, - hold_credits=amount, - hold_record_id=ledger.hold.id, - ledger_state=ledger.state.value, - ), - ) - return result - - if ledger.state == LlmBillingLedgerState.MISSING: - policy = await get_llm_billing_policy( - db, - config_key=ctx.hold_config_key, - explicit_hold_credits=None, - ) - else: - policy = None - if policy is not None and policy.bypassed: - result = LlmHoldValidation( - True, - 0.0, - LlmBillingLedgerState.BILLING_BYPASSED, - "billing_disabled", - ) - _log( - ctx, - LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, - detail=_context_detail( - ctx, - ledger_state=result.state.value, - billing_bypassed=True, - ), - ) - return result - - if policy is not None and not policy.valid: - result = LlmHoldValidation(False, 0.0, LlmBillingLedgerState.INVALID, "billing_config_invalid") - _log( - ctx, - LlmBillingEvent.EXECUTION_BLOCKED, - status="failed", - detail=_context_detail(ctx, ledger_state=result.state.value), - error=policy.error, - ) - return result - - result = LlmHoldValidation( - False, - ledger.hold_amount, - ledger.state, - ledger.reason or f"ledger_{ledger.state.value}", - ledger.hold.id if ledger.hold else None, - ) - event = ( - LlmBillingEvent.HOLD_MISSING - if ledger.state == LlmBillingLedgerState.MISSING - else LlmBillingEvent.EXECUTION_BLOCKED - ) - _log( - ctx, - event, - status="failed", - detail=_context_detail( - ctx, - hold_credits=result.amount, - hold_record_id=result.hold_record_id, - ledger_state=result.state.value, - skip_reason=result.reason, - ), - error="LLM预扣不是有效冻结状态,拒绝调用模型", - ) - return result - - -async def _release_active_hold( +async def mark_business_success( db: AsyncSession, ctx: LlmBillingContext, *, - hold_record: CreditRecord, - reason: str, -) -> BillingItem: - amount = _round2(abs(float(hold_record.amount or 0))) - ctx.hold_credits = amount - if amount <= 0: - _log(ctx, LlmBillingEvent.HOLD_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_record_id=hold_record.id, reason=reason, skip_reason="hold_amount_not_positive")) - return BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=0.0, - charged=False, - skipped_reason="hold_amount_not_positive", - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, - ) - - _log(ctx, LlmBillingEvent.HOLD_RELEASE_START, status="started", detail=_context_detail(ctx, hold_credits=amount, hold_record_id=hold_record.id, reason=reason)) - mutation = await add_credits_result( - db, - user_id=ctx.user_id, - amount=amount, - description=f"{ctx.description_prefix}预扣积分释放", - related_id=ctx.related_id or ctx.owner_id, - record_type="refund", - biz_key=ctx.hold_release_biz_key, - refund_for_biz_key=ctx.hold_biz_key, - record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD_RELEASE.value), - ) - _log( - ctx, - LlmBillingEvent.HOLD_RELEASE_SUCCESS, - detail=_context_detail( - ctx, - hold_credits=amount, - hold_record_id=hold_record.id, - hold_release_record_id=mutation.record.id if mutation.record else None, - reason=reason, - idempotent=not mutation.created, - balance_before=mutation.balance_before, - balance_after=mutation.balance_after, - ), - ) - return BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=amount, - charged=False, - skipped_reason=None if mutation.created else "already_released", - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, - ) - - -async def release_hold(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem: - ledger = await _load_ledger(db, ctx) - - # 即使管理员已经关闭计费,历史 active HOLD 也必须按真实冻结流水释放。 - if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: - return await _release_active_hold(db, ctx, hold_record=ledger.hold, reason=reason) - if ledger.state in (LlmBillingLedgerState.RELEASED, LlmBillingLedgerState.CHARGED): - amount = _round2(abs(float(ledger.release.amount or 0))) if ledger.release else ledger.hold_amount - _log( - ctx, - LlmBillingEvent.HOLD_RELEASE_SKIPPED, - status="skipped", - detail=_context_detail( - ctx, - reason=reason, - hold_credits=amount, - ledger_state=ledger.state.value, - skip_reason="already_released", - idempotent=True, - ), - ) - return BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=amount, - charged=False, - skipped_reason="already_released", - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, - ) - if ledger.state == LlmBillingLedgerState.MISSING: - policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key) - else: - policy = None - if policy is not None and policy.bypassed: - _log( - ctx, - LlmBillingEvent.HOLD_RELEASE_SKIPPED, - status="skipped", - detail=_context_detail( - ctx, - reason=reason, - ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value, - skip_reason="billing_disabled", - ), - ) - return BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=0.0, - charged=False, - skipped_reason="billing_disabled", - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, - ) - - _log( - ctx, - LlmBillingEvent.HOLD_RELEASE_SKIPPED, - status="skipped", - detail=_context_detail( - ctx, - reason=reason, - ledger_state=ledger.state.value, - skip_reason=ledger.reason or ledger.state.value, - ), - error="没有可释放的有效LLM预扣流水", - ) - return BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=0.0, - charged=False, - skipped_reason=ledger.reason or ledger.state.value, - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, - ) - - -async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary: - _log(ctx, LlmBillingEvent.FAILURE_RELEASE_START, status="started", detail=_context_detail(ctx, error=error), error=error) - item = await release_hold(db, ctx, reason="failure") - if item.amount > 0 and not item.skipped_reason: - _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SUCCESS, detail=_context_detail(ctx, hold_credits=item.amount, error=error), error=error) - else: - _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_credits=item.amount, error=error, skip_reason=item.skipped_reason or "hold_not_active"), error=error) - return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item]) - - -def _normalize_settlement_usage( - ctx: LlmBillingContext, usage: Mapping[str, Any], -) -> dict[str, Any]: - snapshot = dict(usage or {}) - try: - if snapshot.get("usage_reported") is False and not bool(snapshot.get("billing_free")): - raise ValueError("provider_usage_missing") - if "input_tokens" not in snapshot or "output_tokens" not in snapshot: - raise ValueError("input_or_output_tokens_missing") - input_tokens = int(snapshot.get("input_tokens")) - output_tokens = int(snapshot.get("output_tokens")) - if input_tokens < 0 or output_tokens < 0: - raise ValueError("token_count_negative") - normalized_total = input_tokens + output_tokens - raw_total = snapshot.get("total_tokens") - if raw_total not in (None, ""): - total_tokens = int(raw_total) - if total_tokens < 0: - raise ValueError("total_tokens_negative") - else: - total_tokens = normalized_total - if total_tokens != normalized_total: - snapshot["reported_total_tokens"] = total_tokens - total_tokens = normalized_total - snapshot["input_tokens"] = input_tokens - snapshot["output_tokens"] = output_tokens - snapshot["total_tokens"] = total_tokens - return snapshot - except (TypeError, ValueError) as exc: - _log( - ctx, - LlmBillingEvent.USAGE_INVALID, - status="failed", - detail=_context_detail(ctx, usage=snapshot, reason=str(exc)), - error="LLM usage 无效,拒绝释放 HOLD 和创建真实扣费", - ) - raise LlmBillingStateError(f"LLM usage 无效:{exc}") from exc - - -async def _ensure_token_usage_once( - db: AsyncSession, - ctx: LlmBillingContext, - usage: dict[str, Any], -) -> TokenUsage: - supplied_id = str(usage.get("token_usage_id") or "").strip() or None - if supplied_id: - supplied_result = await db.execute( - select(TokenUsage).where(TokenUsage.id == supplied_id).limit(1) - ) - supplied = supplied_result.scalar_one_or_none() - if supplied is not None: - if supplied.user_id not in (None, ctx.user_id): - raise LlmBillingStateError("TokenUsage 用户归属与当前账务上下文不一致") - if supplied.biz_key not in (None, ctx.charge_biz_key): - raise LlmBillingStateError("TokenUsage biz_key 与当前 charge 不一致") - supplied.user_id = supplied.user_id or ctx.user_id - supplied.owner_type = supplied.owner_type or ctx.owner_type - supplied.owner_id = supplied.owner_id or ctx.owner_id - supplied.biz_key = supplied.biz_key or ctx.charge_biz_key - supplied.source_module = supplied.source_module or ctx.source_module - supplied.source_step_code = supplied.source_step_code or ctx.source_step_code - usage["token_usage_id"] = supplied.id - ctx.token_usage_id = supplied.id - _log( - ctx, - LlmBillingEvent.TOKEN_USAGE_REUSED, - detail=_context_detail(ctx, token_usage_id=supplied.id, source="supplied_id"), - ) - return supplied - - result = await db.execute( - select(TokenUsage) - .where( - TokenUsage.user_id == ctx.user_id, - TokenUsage.biz_key == ctx.charge_biz_key, - ) + description: str | None = None, +) -> BillingSummary: + # 调用审计使用独立短事务,必须在当前 Session 锁定 execution 之前完成。 + # 若先锁 execution 再等待独立 Session 更新同一行,会形成自锁死等待。 + if ctx.current_call_attempt_id: + await finish_call_success(ctx, usage=usage) + execution = await _find_execution(db, ctx, for_update=True) + if execution is None: + raise LlmBillingStateError("LLM固定预扣执行记录不存在") + ctx.billing_execution_id = execution.id + created = execution.status != LlmBillingExecutionStatus.SUCCEEDED.value + if execution.status in {LlmBillingExecutionStatus.REFUNDED.value, LlmBillingExecutionStatus.FINAL_FAILED.value}: + raise LlmBillingStateError("LLM业务已最终失败并退款,不能再标记成功") + execution.status = LlmBillingExecutionStatus.SUCCEEDED.value + execution.completed_at = utc_now() + call_result = await db.execute( + select(LlmCallAttempt) + .where(LlmCallAttempt.billing_execution_id == execution.id) + .order_by(LlmCallAttempt.call_sequence.desc()) .limit(1) ) - existing = result.scalar_one_or_none() - if existing is not None: - usage["token_usage_id"] = existing.id - ctx.token_usage_id = existing.id - _log( - ctx, - LlmBillingEvent.TOKEN_USAGE_REUSED, - detail=_context_detail(ctx, token_usage_id=existing.id, source="biz_key"), - ) - return existing - - token_usage = TokenUsage( - id=generate_id(), - model_config_id=usage.get("model_config_id"), + last_call = call_result.scalar_one_or_none() + if last_call: + last_call.postprocess_status = "succeeded" + last_call.postprocess_error = None + record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update()) + record = record_result.scalar_one_or_none() + if record: + record.input_tokens = execution.total_input_tokens + record.output_tokens = execution.total_output_tokens + record.total_tokens = execution.total_tokens + record.llm_call_count = execution.total_call_count + record.llm_success_call_count = execution.successful_call_count + record.llm_failed_call_count = execution.failed_call_count + _log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, pre_deduct_credits=to_float(execution.pre_deduct_credits), idempotent=not created) + return BillingSummary( + record_id=ctx.owner_id, user_id=ctx.user_id, - input_tokens=int(usage["input_tokens"]), - output_tokens=int(usage["output_tokens"]), - total_tokens=int(usage["total_tokens"]), - owner_type=ctx.owner_type, - owner_id=ctx.owner_id, - biz_key=ctx.charge_biz_key, - source_module=ctx.source_module, - source_step_code=ctx.source_step_code, - ) - try: - async with db.begin_nested(): - db.add(token_usage) - await db.flush() - except IntegrityError: - if token_usage in db.sync_session: - db.sync_session.expunge(token_usage) - result = await db.execute( - select(TokenUsage) - .where( - TokenUsage.user_id == ctx.user_id, - TokenUsage.biz_key == ctx.charge_biz_key, + items=[ + BillingItem( + charge_key=ctx.charge_kind, + amount=to_float(execution.pre_deduct_credits), + charged=created, + skipped_reason=None if created else "already_succeeded", + biz_key=ctx.billing_biz_key, + attempt_no=ctx.attempt_no, ) - .limit(1) - ) - token_usage = result.scalar_one_or_none() - if token_usage is None: - raise - event = LlmBillingEvent.TOKEN_USAGE_REUSED - else: - event = LlmBillingEvent.TOKEN_USAGE_CREATED - - usage["token_usage_id"] = token_usage.id - ctx.token_usage_id = token_usage.id - _log( - ctx, - event, - detail=_context_detail( - ctx, - token_usage_id=token_usage.id, - input_tokens=token_usage.input_tokens, - output_tokens=token_usage.output_tokens, - total_tokens=token_usage.total_tokens, - ), - ) - return token_usage - - -async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta: - usage_snapshot = dict(usage or {}) - ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider - ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name - ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id - if ctx.owner_type == CreditRecordOwnerType.GENERATION_RECORD.value: - return await build_generation_record_prompt_meta( - db, - record_id=ctx.owner_id, - attempt_no=ctx.attempt_no, - charge_kind=ctx.charge_kind, - usage=usage_snapshot, - ) - if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value: - return await build_module_step_prompt_meta( - db, - step_id=ctx.owner_id, - attempt_no=ctx.attempt_no, - usage=usage_snapshot, - ) - if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value: - return await build_shot_video_analysis_meta( - db, - owner_type=ctx.owner_type, - owner_id=ctx.owner_id, - attempt_no=ctx.attempt_no, - usage=usage_snapshot, - billing_scene=ctx.billing_scene or CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value, - source_project_id=ctx.source_project_id, - source_step_id=ctx.source_step_id, - ) - return CreditRecordMeta( - owner_type=ctx.owner_type, - owner_id=ctx.owner_id, - attempt_no=ctx.attempt_no, - charge_kind=ctx.charge_kind, - charge_action=CreditRecordAction.CHARGE.value, - credit_subject=CreditRecordSubject.TEXT.value, - billing_scene=ctx.billing_scene, - source_module=ctx.source_module, - source_project_id=ctx.source_project_id, - source_step_id=ctx.source_step_id, - source_step_code=ctx.source_step_code, - token_usage_id=usage_snapshot.get("token_usage_id"), - input_tokens=_safe_int(usage_snapshot.get("input_tokens")), - output_tokens=_safe_int(usage_snapshot.get("output_tokens")), - total_tokens=_safe_int(usage_snapshot.get("total_tokens")), + ], ) -async def _settle_success_impl( - db: AsyncSession, - ctx: LlmBillingContext, - *, - usage: Mapping[str, Any], - description: str | None = None, -) -> BillingSummary: - ledger = await _load_ledger(db, ctx) +async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary: + from app.services.credit.ledger_service import refund_consumption - if ledger.state == LlmBillingLedgerState.CHARGED and ledger.hold and ledger.release and ledger.charge: - release_item = BillingItem( - charge_key=CreditRecordAction.HOLD_RELEASE.value, - amount=abs(_round2(ledger.release.amount)), - charged=False, - skipped_reason="already_released", - biz_key=ctx.hold_release_biz_key, - attempt_no=ctx.attempt_no, + # 与固定预扣保持 user advisory -> execution -> balance 的统一锁顺序。 + await acquire_user_credit_lock(db, ctx.user_id) + execution = await _find_execution(db, ctx, for_update=True) + if execution is None: + return BillingSummary(ctx.owner_id, ctx.user_id, []) + ctx.billing_execution_id = execution.id + if execution.status == LlmBillingExecutionStatus.REFUNDED.value: + return BillingSummary( + ctx.owner_id, + ctx.user_id, + [BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)], ) - charge_item = BillingItem( - charge_key=ctx.charge_kind, - amount=abs(_round2(ledger.charge.amount)), - charged=False, - skipped_reason="already_charged", - biz_key=ctx.charge_biz_key, - attempt_no=ctx.attempt_no, - ) - _log( - ctx, - LlmBillingEvent.SETTLE_SUCCESS, - detail=_context_detail( - ctx, - actual_credits=charge_item.amount, - ledger_state=ledger.state.value, - idempotent=True, - ), - ) - return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item]) - - # 新任务在关闭计费时没有 HOLD,成功后直接绕过;历史 active HOLD 则必须继续结算。 - if ledger.state == LlmBillingLedgerState.MISSING: - policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key) - else: - policy = None - if policy is not None and policy.bypassed: - _log( - ctx, - LlmBillingEvent.SETTLE_SUCCESS, - status="skipped", - detail=_context_detail( - ctx, - ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value, - billing_bypassed=True, - ), - ) - return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[]) - - if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None: - # 统一由 settle_success 外层记录一次 SETTLE_FAILED,避免同一异常产生重复日志。 - raise LlmBillingStateError( - f"当前attempt账务状态为{ledger.state.value},不能执行成功结算" - ) - - normalized_usage = _normalize_settlement_usage(ctx, usage) - await _ensure_token_usage_once(db, ctx, normalized_usage) - _log( - ctx, - LlmBillingEvent.SETTLE_START, - status="started", - detail=_context_detail( - ctx, - ledger_state=ledger.state.value, - input_tokens=normalized_usage["input_tokens"], - output_tokens=normalized_usage["output_tokens"], - total_tokens=normalized_usage["total_tokens"], - provider=normalized_usage.get("provider") or normalized_usage.get("model_provider"), - model_name=normalized_usage.get("model_name") or normalized_usage.get("model"), - ), - ) - input_tokens = int(normalized_usage["input_tokens"]) - output_tokens = int(normalized_usage["output_tokens"]) - amount = await calc_text_credits(db, input_tokens, output_tokens) - meta = await _build_charge_meta(db, ctx, normalized_usage) - release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success") - if meta.charge_action is None: - meta.charge_action = CreditRecordAction.CHARGE.value - meta.billing_scene = meta.billing_scene or ctx.billing_scene - meta.source_module = meta.source_module or ctx.source_module - meta.source_project_id = meta.source_project_id or ctx.source_project_id - meta.source_step_id = meta.source_step_id or ctx.source_step_id - meta.source_step_code = meta.source_step_code or ctx.source_step_code - - mutation = await deduct_credits_result( - db, - user_id=ctx.user_id, - amount=amount, - description=description or f"{ctx.description_prefix}真实扣费", - related_id=ctx.related_id or ctx.owner_id, - biz_key=ctx.charge_biz_key, - record_meta=meta, - allow_negative=True, - create_zero_record=True, - ) - charged_amount = mutation.amount - charge_item = BillingItem( - charge_key=ctx.charge_kind, - amount=charged_amount, - charged=mutation.created, - skipped_reason=None if mutation.created else "already_charged", - biz_key=ctx.charge_biz_key, - attempt_no=ctx.attempt_no, - ) - - if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value: - result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == ctx.owner_id).limit(1)) - step = result.scalar_one_or_none() - if step: - step.token_usage_id = meta.token_usage_id - step.model_config_id = normalized_usage.get("model_config_id") - step.input_tokens = meta.input_tokens - step.output_tokens = meta.output_tokens - step.total_tokens = meta.total_tokens - step.text_credits_cost = charged_amount - - after_balance = mutation.balance_after - _log( - ctx, - LlmBillingEvent.CHARGE_SUCCESS, - detail=_context_detail( - ctx, - actual_credits=charged_amount, - charge_record_id=mutation.record.id if mutation.record else None, - idempotent=not mutation.created, - balance_before=mutation.balance_before, - balance_after=after_balance, - allow_negative=True, - ), - ) - if after_balance < 0: - _log(ctx, LlmBillingEvent.CHARGE_NEGATIVE_BALANCE, status="warning", detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, allow_negative=True)) - _log(ctx, LlmBillingEvent.SETTLE_SUCCESS, detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, ledger_state=LlmBillingLedgerState.CHARGED.value, idempotent=not mutation.created)) - return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item]) - -async def settle_success( - db: AsyncSession, - ctx: LlmBillingContext, - *, - usage: Mapping[str, Any], - description: str | None = None, -) -> BillingSummary: - """成功结算统一入口;任何异常都留下可检索的 SETTLE_FAILED 日志。""" + if execution.status == LlmBillingExecutionStatus.SUCCEEDED.value: + # 调用方只有在业务最终失败时才能进这里;允许从业务成功调用记录转最终失败退款。 + pass + execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value + execution.final_error_message = str(error or "业务最终失败")[:1000] + execution.completed_at = utc_now() + _log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error) try: - return await _settle_success_impl( + refunded = await refund_consumption( db, + user_id=ctx.user_id, + refund_for_biz_key=ctx.pre_deduct_biz_key, + description=f"{execution.scene_name_snapshot}最终失败", + related_id=ctx.related_id or ctx.owner_id, + biz_key=f"{ctx.pre_deduct_biz_key}:business-final-refund", + record_meta={ + "owner_type": ctx.owner_type, + "owner_id": ctx.owner_id, + "attempt_no": ctx.attempt_no, + "charge_kind": ctx.charge_kind, + "charge_action": CreditRecordAction.REFUND.value, + "credit_subject": CreditRecordSubject.REFUND.value, + "billing_scene": ctx.billing_scene, + "scene_name_snapshot": execution.scene_name_snapshot, + "source_module": ctx.source_module, + "source_project_id": ctx.source_project_id, + "source_step_id": ctx.source_step_id, + "source_step_code": ctx.source_step_code, + "llm_billing_execution_id": execution.id, + }, + refund_time=utc_now(), + ) + execution.refund_available_credits = refunded.available_amount + execution.refund_expired_credits = refunded.expired_amount + execution.status = LlmBillingExecutionStatus.REFUNDED.value + execution.refunded_at = utc_now() + _log( ctx, - usage=usage, - description=description, + LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS, + refund_available=to_float(refunded.available_amount), + refund_expired=to_float(refunded.expired_amount), + ) + return BillingSummary( + ctx.owner_id, + ctx.user_id, + [BillingItem(ctx.charge_kind, to_float(refunded.total_amount), True, None, ctx.refund_biz_key, ctx.attempt_no)], ) except Exception as exc: - _log( - ctx, - LlmBillingEvent.SETTLE_FAILED, - status="failed", - detail=_context_detail(ctx, error_type=type(exc).__name__), - error=str(exc), - ) + execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value + _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc)) + raise + + +async def refund_pre_deduct(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem: + summary = await refund_on_final_failure(db, ctx, error=reason) + return summary.items[0] if summary.items else BillingItem(ctx.charge_kind, 0.0, False, "missing", ctx.refund_biz_key, ctx.attempt_no) + + +async def finalize_llm_business_failure( + ctx: LlmBillingContext, + *, + error: str | None = None, +) -> BillingSummary: + """在独立短事务中完成最终失败退款,并确保退款失败状态不会被业务 rollback 吞掉。""" + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + return await refund_on_final_failure(session, ctx, error=error) + except Exception as exc: + try: + async with AsyncSessionLocal() as audit_session: + async with audit_session.begin(): + execution = await _find_execution(audit_session, ctx, for_update=True) + if execution is not None and execution.status != LlmBillingExecutionStatus.REFUNDED.value: + execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value + execution.final_error_message = str(error or exc)[:1000] + execution.completed_at = utc_now() + except Exception: + _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), persist_failed_status=False) raise diff --git a/video-gen-api/app/services/module_async_recovery_service.py b/video-gen-api/app/services/module_async_recovery_service.py index 6e351e50..e624c998 100644 --- a/video-gen-api/app/services/module_async_recovery_service.py +++ b/video-gen-api/app/services/module_async_recovery_service.py @@ -19,7 +19,7 @@ from app.enums.credit_record import ( CreditRecordChargeKind, CreditRecordOwnerType, ) -from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState +from app.enums.llm_billing import LlmBillingLedgerState from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum from app.enums.shot_replicate import ( ModuleCodeEnum as ShotModuleCodeEnum, @@ -38,8 +38,7 @@ from app.services.redis_registry_service import ( utc_now, ) from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values -from app.services.llm_billing import LlmBillingContext, LlmHoldValidation, get_llm_ledger_states -from app.services.llm_billing.config import get_llm_billing_policy +from app.services.llm_billing import LlmBillingContext, LlmPreDeductValidation, get_llm_ledger_states from app.tasks.celery_app import celery_app logger = logging.getLogger("video_gen") @@ -93,12 +92,7 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext: source_step_id=str(step.id), source_step_code=str(step.step_code), related_id=str(step.id), - hold_config_key=( - LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value - if is_image_prompt - else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value - ), - description_prefix="模块AI提词优化", + description_prefix="模块AI提词优化", trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}", ) @@ -106,35 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext: async def _load_step_billing_validations( db: AsyncSession, steps: Iterable[ModuleGenerationStep], -) -> dict[str, LlmHoldValidation]: +) -> dict[str, LlmPreDeductValidation]: step_list = list(steps) if not step_list: return {} contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list} - policies = {} - for config_key in {ctx.hold_config_key for ctx in contexts.values() if ctx.hold_config_key}: - policies[config_key] = await get_llm_billing_policy(db, config_key=config_key) - # 无论当前配置是否关闭,都批量读取历史 attempt 流水:运行中的 active HOLD - # 必须继续结算,不能因后台关闭计费而被当成 bypass 遗留冻结。 + # 固定预扣已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录, + # 不再读取旧 SystemConfig 冻结配置;缺少固定预扣记录时直接终止恢复。 ledger_states = await get_llm_ledger_states(db, contexts.values()) - output: dict[str, LlmHoldValidation] = {} + output: dict[str, LlmPreDeductValidation] = {} for step_id, ctx in contexts.items(): - policy = policies.get(ctx.hold_config_key) ledger = ledger_states.get( - ctx.hold_biz_key, - LlmHoldValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), + ctx.pre_deduct_biz_key, + LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), ) if ledger.state == LlmBillingLedgerState.ACTIVE: output[step_id] = ledger - elif ledger.state == LlmBillingLedgerState.MISSING and policy is not None and policy.bypassed: - output[step_id] = LlmHoldValidation( - True, - 0.0, - LlmBillingLedgerState.BILLING_BYPASSED, - "billing_disabled", - ) - elif ledger.state == LlmBillingLedgerState.MISSING and (policy is None or not policy.valid): - output[step_id] = LlmHoldValidation( + elif ledger.state == LlmBillingLedgerState.MISSING: + output[step_id] = LlmPreDeductValidation( False, 0.0, LlmBillingLedgerState.INVALID, diff --git a/video-gen-api/app/services/module_generation_v2/flow_service.py b/video-gen-api/app/services/module_generation_v2/flow_service.py index 0be13454..90d9ff28 100644 --- a/video-gen-api/app/services/module_generation_v2/flow_service.py +++ b/video-gen-api/app/services/module_generation_v2/flow_service.py @@ -19,7 +19,6 @@ from app.enums.common import ( ) from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType from app.enums.generation_task import ChatGenerationTaskStatus -from app.enums.llm_billing import LlmBillingConfigKey from app.enums.shot_replicate import ( ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum, @@ -51,13 +50,14 @@ from app.services.generation.pipeline.db_lock_service import ( from app.services.generation.task_factory_service import create_chat_generation_task_for_module from app.services.llm_billing import ( LlmBillingContext, - ensure_hold_exists, + ensure_pre_deducted, log_provider_failure, log_provider_start, log_provider_success, - release_on_failure, - settle_success, - start_hold, + mark_business_success, + pre_deduct, + record_provider_exception, + finalize_llm_business_failure, ) from app.services.hot_opening_video_prompt_service import ( build_final_video_prompt, @@ -369,8 +369,7 @@ def build_v2_video_prompt_billing_context( source_step_id=str(step_id), source_step_code=VIDEO_PROMPT_OPTIMIZE, related_id=str(step_id), - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, - description_prefix=f"{display_name}视频提词优化", + description_prefix=f"{display_name}视频提词优化", trace_id=f"module-v2-video-prompt:{step_id}", ) @@ -433,7 +432,7 @@ async def create_hot_opening_project_v2( video_config=video_config, target_platform=req.target_platform or "抖音", ) - await start_hold( + await pre_deduct( db, build_v2_video_prompt_billing_context( user_id=str(current_user.id), @@ -600,7 +599,7 @@ async def create_shot_replicate_project_v2( urls=[req.material_image_url], allow_common_migrate=True, ) - await start_hold( + await pre_deduct( db, build_v2_video_prompt_billing_context( user_id=str(current_user.id), @@ -724,7 +723,7 @@ async def rebuild_video_prompt_step_v2( project.final_video_cover_url = None project.completed_at = None project.error_message = None - await start_hold( + await pre_deduct( db, build_v2_video_prompt_billing_context( user_id=str(project.user_id), @@ -799,19 +798,16 @@ async def mark_video_prompt_dispatch_failed_v2( message=error_message, detail={"dispatch_compensated": True}, ) - await release_on_failure( - db, - build_v2_video_prompt_billing_context( - user_id=str(project.user_id), - project_id=str(project.id), - step_id=str(step.id), - step_version=int(step.version or 1), - module=config.module, - display_name=config.display_name, - ), - error=error_message, + billing_context = build_v2_video_prompt_billing_context( + user_id=str(project.user_id), + project_id=str(project.id), + step_id=str(step.id), + step_version=int(step.version or 1), + module=config.module, + display_name=config.display_name, ) await db.commit() + await finalize_llm_business_failure(billing_context, error=error_message) async def run_video_prompt_optimize_v2( @@ -888,14 +884,13 @@ async def run_video_prompt_optimize_v2( source_step_id=project_snapshot["step_id"], source_step_code=VIDEO_PROMPT_OPTIMIZE, related_id=project_snapshot["step_id"], - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, description_prefix=f"{config.display_name}视频提词优化", trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.completed_at = utc_now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -906,7 +901,7 @@ async def run_video_prompt_optimize_v2( provider_succeeded = False usage: dict[str, Any] = {} - log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"}) + await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"}) prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt( db, user_id=project_snapshot["user_id"], @@ -921,9 +916,11 @@ async def run_video_prompt_optimize_v2( module=project_snapshot["module"], project_id=project_snapshot["project_id"], step_id=project_snapshot["step_id"], + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=usage) + await log_provider_success(db, llm_billing_context, usage=usage) if execution_guard is not None: await execution_guard() @@ -945,24 +942,20 @@ async def run_video_prompt_optimize_v2( row = locked.first() if not row: await db.rollback() - # Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。 - await settle_success( - db, + await log_provider_failure(db, llm_billing_context, error="业务对象已失效,供应商结果无法落库") + await finalize_llm_business_failure( llm_billing_context, - usage=usage, - description=f"{config.display_name}-视频提词优化(业务对象失效结算)", + error="业务对象已失效,供应商结果无法落库", ) - await db.commit() return None project, step = row if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value: - await settle_success( - db, + await db.rollback() + await log_provider_failure(db, llm_billing_context, error="业务步骤版本已失效,供应商结果被丢弃") + await finalize_llm_business_failure( llm_billing_context, - usage=usage, - description=f"{config.display_name}-视频提词优化(失效结果结算)", + error="业务步骤版本已失效,供应商结果被丢弃", ) - await db.commit() log_module_event_file( module=project_snapshot["module"], event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value, @@ -973,7 +966,7 @@ async def run_video_prompt_optimize_v2( detail={"expected_version": expected_version}, ) return None - billing = await settle_success( + billing = await mark_business_success( db, llm_billing_context, usage=usage, @@ -1018,33 +1011,24 @@ async def run_video_prompt_optimize_v2( ) await db.commit() return step - except DatabaseRowLockBusy: + except DatabaseRowLockBusy as exc: await db.rollback() - if locals().get("provider_succeeded", False): - await settle_success( - db, + if "llm_billing_context" in locals() and locals().get("provider_succeeded", False): + await log_provider_failure(db, llm_billing_context, error="业务行锁失败,供应商结果无法落库") + await finalize_llm_business_failure( llm_billing_context, - usage=locals().get("usage") or {}, - description=f"{config.display_name}-视频提词优化(行锁失败结算)", + error="业务行锁失败,供应商结果无法落库", ) - await db.commit() return None - raise + raise exc except Exception as exc: await db.rollback() - if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): - log_provider_failure(llm_billing_context, error=str(exc)) + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False): + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, usage = await record_provider_exception(db, llm_billing_context, exc) try: - if "llm_billing_context" in locals(): - if locals().get("provider_succeeded", False): - await settle_success( - db, - llm_billing_context, - usage=locals().get("usage") or {}, - description=f"{config.display_name}-视频提词优化(本地失败结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() result = await execute_with_lock_timeout( @@ -1070,9 +1054,9 @@ async def run_video_prompt_optimize_v2( step.completed_at = utc_now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = str(exc) if str(exc) else type(exc).__name__ - # provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE; - # 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。 await db.commit() + if "llm_billing_context" in locals(): + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) log_module_error( module=row[0].module if row else "module_generation_v2", event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, @@ -1092,8 +1076,11 @@ async def run_video_prompt_optimize_v2( detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)}, exc=mark_exc, ) - if locals().get("provider_succeeded", False): - raise + try: + if "llm_billing_context" in locals(): + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) + except Exception: + pass return None diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index bc269881..3c267a1d 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.payment_order import PaymentOrder +from app.models.credit.product import CreditProduct +from app.models.user import User from app.models.system_config import SystemConfig from app.enums.credit_record import ( CreditRecordAction, @@ -27,6 +29,10 @@ from app.enums.credit_record import ( ) from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta from app.services.credits import add_credits, deduct_credits +from app.services.credit.product_service import product_to_dict +from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits +from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation +from app.services.credit.utils import to_credit_decimal, utc_now from app.utils.id_gen import generate_id, generate_order_no @@ -175,6 +181,8 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool expiry = order.created_at + timedelta(seconds=expire_seconds) if datetime.now(order.created_at.tzinfo) >= expiry: order.status = "cancelled" + if order.upgrade_period_ids_json: + await release_upgrade_reservation(db, order=order, released_at=utc_now()) await db.flush() logger.info( f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} " @@ -212,6 +220,8 @@ async def expire_all_pending_orders(db: AsyncSession) -> int: expired_count = 0 for o in orders: o.status = "cancelled" + if o.upgrade_period_ids_json: + await release_upgrade_reservation(db, order=o, released_at=utc_now()) expired_count += 1 logger.info( f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}" @@ -271,7 +281,7 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway: config.alipay_public_key = public_key config.sign_type = "RSA2" config.charset = "utf-8" - + try: _alipay_client = DefaultAlipayClient(config, logger) _alipay_client_app_id = app_id @@ -291,23 +301,18 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway: async def create_recharge_order( db: AsyncSession, user_id: str, - credits: float, - price: float, - label: str, + credits: float = 0.0, + price: float = 0.0, + label: str = "积分商品", bonus_credits: float = 0.0, method: str = "wechat", + *, + product_id: str | None = None, + request_time: datetime | None = None, ) -> PaymentOrder: - """Create a payment order. - - Reads payment config from the database (admin panel). - Returns the order; for Alipay the ``qr_url`` attribute will be populated - with the scan-to-pay URL. - """ - # Read config from database first + """创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。""" db_configs = await _get_payment_configs(db) mock_mode = _is_mock_mode(db_configs) - - # In real mode, validate that the payment method is enabled and configured if not mock_mode: enabled_key = f"payment_{method}_enabled" if db_configs.get(enabled_key, "").lower() != "true": @@ -321,69 +326,120 @@ async def create_recharge_order( "payment_wechat_mch_id", "payment_wechat_private_key", "payment_wechat_cert_serial_no", - "payment_wechat_api_v3_key" + "payment_wechat_api_v3_key", ] - missing_configs = [c for c in required_configs if not db_configs.get(c)] + missing_configs = [key for key in required_configs if not db_configs.get(key)] if missing_configs: raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员") - total_credits = credits + bonus_credits + checked_at = request_time or utc_now() + order_id = generate_id() + order_no = generate_order_no() + product: CreditProduct | None = None + quote = None + + # 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id + # 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。 order = PaymentOrder( - id=generate_id(), + id=order_id, user_id=user_id, - order_no=generate_order_no(), + order_no=order_no, amount=price, - credits=total_credits, + credits=round(float(credits or 0) + float(bonus_credits or 0), 2), payment_method=method, status="pending", + purchase_scene="legacy_recharge", + price_type="regular", + product_name_snapshot=label, + target_price_snapshot=price, + deduction_amount_snapshot=0, + payable_amount_snapshot=price, ) db.add(order) await db.flush() + + if product_id: + product_result = await db.execute( + select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1) + ) + product = product_result.scalar_one_or_none() + if product is None: + raise ValueError("积分商品不存在或已下架") + # 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。 + # 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。 + user_result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = user_result.scalar_one_or_none() + if user is None: + raise ValueError("用户不存在") + quote = await quote_and_reserve_product_purchase( + db, user=user, product=product, order_id=order.id, request_time=checked_at + ) + price = float(quote.payable_amount) + label = product.name + credits = float(product.grant_credits or product.monthly_grant_credits or 0) + bonus_credits = 0.0 + order.amount = quote.payable_amount + order.credits = round(float(credits or 0), 2) + order.product_id = product.id + order.product_type = product.product_type + order.purchase_scene = quote.purchase_scene + order.price_type = quote.price_type + order.product_code_snapshot = product.product_code + order.product_name_snapshot = product.name + product_snapshot = product_to_dict(product) + for time_key in ("activity_start_at", "activity_end_at"): + value = product_snapshot.get(time_key) + if value is not None: + product_snapshot[time_key] = value.isoformat() + order.product_snapshot_json = product_snapshot + order.source_subscription_id = quote.source_subscription_id + order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None + order.target_price_snapshot = quote.target_price + order.deduction_amount_snapshot = quote.deduction_amount + order.payable_amount_snapshot = quote.payable_amount + order.fulfillment_status = "pending" + + total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2) + await db.flush() logger.info( - f"ORDER_CREATED order_no={order.order_no} user={user_id} " - f"amount={price} credits={total_credits} method={method} mock={mock_mode}" + f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} " + f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}" ) if mock_mode: - # Mock: immediately complete payment order.status = "paid" - order.paid_at = datetime.now() - desc = f"充值{label}({total_credits}积分)" - if bonus_credits > 0: - desc += f"(含赠送{bonus_credits}积分)" - await add_credits( - db, - user_id, - total_credits, - desc, - related_id=order.id, - biz_key=_payment_biz_key( - order, - charge_kind=CreditRecordChargeKind.RECHARGE.value, - action=CreditRecordAction.CHARGE.value, - ), - record_meta=_payment_recharge_meta(order), - ) + order.paid_at = checked_at + if product: + await fulfill_payment_product(db, order=order, fulfilled_at=checked_at) + else: + desc = f"充值{label}({total_credits}积分)" + if bonus_credits > 0: + desc += f"(含赠送{bonus_credits}积分)" + await add_credits( + db, user_id, total_credits, desc, related_id=order.id, + biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), + record_meta=_payment_recharge_meta(order), + payment_order_id=order.id, + source_id=order.id, + ) await db.flush() else: - # Real payment: delegate to WeChat or Alipay if method == "wechat": qr_code_content = _create_wechat_order(order, db_configs) if qr_code_content: - # Attach QR code content to the order instance (transient, not persisted) order.qr_url = qr_code_content # type: ignore[attr-defined] else: - # Precreate failed — do not leave a pending order that can never be paid + if quote and quote.upgrade_period_ids: + await release_upgrade_reservation(db, order=order, released_at=checked_at) raise ValueError("微信支付预下单失败,请检查配置或稍后重试") elif method == "alipay": qr_url = _create_alipay_order(order, db_configs) if qr_url: - # Attach QR URL to the order instance (transient, not persisted) order.qr_url = qr_url # type: ignore[attr-defined] else: - # Precreate failed — do not leave a pending order that can never be paid + if quote and quote.upgrade_period_ids: + await release_upgrade_reservation(db, order=order, released_at=checked_at) raise ValueError("支付宝预下单失败,请检查配置或稍后重试") - return order @@ -471,7 +527,7 @@ def _get_wechat_client( def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None: """Create a WeChat Pay Native order. Returns QR code content (code_url). - + Reads all WeChat config from the database (admin panel). Returns the ``code_url`` on success, or ``None`` on failure. """ @@ -505,9 +561,9 @@ def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str "payer_client_ip": "127.0.0.1", } ) - + data = _parse_wechat_result(result) - + if code == 200 and data.get('code_url'): # 注意:微信返回的 code_url 可能需要进一步处理成二维码图片地址 logger.info(f"WeChat order created successfully: order_no={order.order_no}") @@ -608,7 +664,7 @@ async def _refund_wechat_order( db_configs: dict[str, str] ) -> dict: """Call WeChat Pay refund API. - + 微信退款是异步的,调用后会返回PROCESSING状态,实际退款结果通过回调通知。 """ mch_id = db_configs.get("payment_wechat_mch_id", "") @@ -641,9 +697,9 @@ async def _refund_wechat_order( }, reason=refund_reason ) - + data = _parse_wechat_result(result) - + # 微信退款是异步的,PROCESSING是正常状态,表示退款已受理 if code == 200: status = data.get('status') @@ -653,18 +709,18 @@ async def _refund_wechat_order( f"status={status}, refund_id={data.get('refund_id')}" ) return { - "success": True, + "success": True, "refund_id": data.get('refund_id'), "status": status, "message": "退款申请已提交,等待微信处理" } - + logger.error( f"WeChat refund failed: order_no={order.order_no}, " f"code={code}, result={result}" ) return { - "success": False, + "success": False, "message": f"微信退款失败: code={code}, {data.get('message', '')}" } except Exception as e: @@ -723,7 +779,7 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str # 构造请求 request = AlipayTradePrecreateRequest(biz_model=model) - + # 设置 notify_url 在 request 上 if notify_url: try: @@ -925,6 +981,8 @@ async def sync_pending_orders(db: AsyncSession) -> int: elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"): # Order was closed on Alipay side order.status = "cancelled" + if order.upgrade_period_ids_json: + await release_upgrade_reservation(db, order=order, released_at=utc_now()) await db.flush() updated_count += 1 elif order.payment_method == "wechat": @@ -940,6 +998,8 @@ async def sync_pending_orders(db: AsyncSession) -> int: elif trade_state in ("CLOSED", "REVOKED"): # Order was closed on WeChat side order.status = "cancelled" + if order.upgrade_period_ids_json: + await release_upgrade_reservation(db, order=order, released_at=utc_now()) await db.flush() updated_count += 1 except Exception as e: @@ -1010,43 +1070,43 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool: def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type: str = "RSA2") -> bool: """Verify Alipay RSA/RSA2 signature. - + Args: public_key: Alipay public key (PEM format, with or without headers) sign_content: Original content to verify sign: Base64 encoded signature sign_type: "RSA" (SHA1) or "RSA2" (SHA256) - + Returns: True if signature is valid """ try: import base64 from hashlib import sha1, sha256 - + # 处理公钥,确保有正确的格式 pub_key = public_key.strip() if not pub_key.startswith("-----BEGIN"): pub_key = "-----BEGIN PUBLIC KEY-----\n" + pub_key + "\n-----END PUBLIC KEY-----" - + try: from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend - + # 加载公钥 public_key_obj = serialization.load_pem_public_key( pub_key.encode("utf-8"), backend=default_backend() ) - + # 选择哈希算法 if sign_type == "RSA2": hash_alg = hashes.SHA256() else: hash_alg = hashes.SHA1() - + # 验证签名 public_key_obj.verify( base64.b64decode(sign), @@ -1055,21 +1115,21 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type hash_alg ) return True - + except ImportError: # 如果没有 cryptography,尝试使用 rsa 库 try: import rsa - + # 加载公钥 pub_key_obj = rsa.PublicKey.load_pkcs1_openssl_pem(pub_key.encode("utf-8")) - + # 选择哈希算法 if sign_type == "RSA2": hash_func = 'SHA-256' else: hash_func = 'SHA-1' - + # 验证签名 rsa.verify( sign_content.encode("utf-8"), @@ -1078,13 +1138,13 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type hash_func ) return True - + except ImportError: logger.error("Neither cryptography nor rsa library installed, cannot verify signature") # 如果没有任何加密库,在生产环境应该返回 False,但这里我们记录警告并继续 logger.warning("Skipping signature verification due to missing crypto libraries") return False - + except Exception as e: logger.exception(f"Signature verification failed: {e}") return False @@ -1097,7 +1157,7 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: """Verify WeChat payment callback signature. - + Note: Since we're using wechatpayv3 SDK which handles verification internally, in the callback handler we'll verify with the SDK. This function is kept for interface consistency and mock mode support. @@ -1107,7 +1167,7 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: if mock_mode: logger.info("Mock mode: skipping WeChat callback verification") return True - + # 对于真实模式,我们在回调路由处理器中直接使用 SDK 验证 # 这里我们返回 True 以保持接口一致性 logger.info("WeChat callback verification (delegated to SDK in router)") @@ -1130,19 +1190,17 @@ async def process_payment_success(db: AsyncSession, order_id: str): order.status = "paid" order.paid_at = datetime.now() - await add_credits( - db, - order.user_id, - order.credits, - f"充值成功({order.credits}积分)", - related_id=order.id, - biz_key=_payment_biz_key( - order, - charge_kind=CreditRecordChargeKind.RECHARGE.value, - action=CreditRecordAction.CHARGE.value, - ), - record_meta=_payment_recharge_meta(order), - ) + if order.product_id: + await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at) + else: + await add_credits( + db, order.user_id, order.credits, f"充值成功({order.credits}积分)", + related_id=order.id, + biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), + record_meta=_payment_recharge_meta(order), + payment_order_id=order.id, + source_id=order.id, + ) await db.commit() @@ -1164,21 +1222,21 @@ async def process_payment_success_by_order_no( select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) ) order = result.scalar_one_or_none() - + if not order: logger.info(f"Order {order_no} not found, skipping") return - + if order.status == "paid": logger.info(f"Order {order_no} already processed, skipping") return - + if order.status != "pending": logger.info(f"Order {order_no} is in {order.status} state, cannot process") return # 金额一致性校验 - if total_amount is not None and abs(total_amount - order.amount) > 0.01: + if total_amount is not None and abs(to_credit_decimal(total_amount) - to_credit_decimal(order.amount)) > to_credit_decimal("0.01"): logger.error( f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}" ) @@ -1194,19 +1252,18 @@ async def process_payment_success_by_order_no( if trade_no: order.trade_no = trade_no - await add_credits( - db, - order.user_id, - order.credits, - f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}", - related_id=order.id, - biz_key=_payment_biz_key( - order, - charge_kind=CreditRecordChargeKind.RECHARGE.value, - action=CreditRecordAction.CHARGE.value, - ), - record_meta=_payment_recharge_meta(order), - ) + if order.product_id: + await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at) + else: + await add_credits( + db, order.user_id, order.credits, + f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}", + related_id=order.id, + biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), + record_meta=_payment_recharge_meta(order), + payment_order_id=order.id, + source_id=order.id, + ) await db.commit() logger.info( f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} " @@ -1235,22 +1292,22 @@ async def process_refund( select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1) ) order = result.scalar_one_or_none() - + if not order: return {"success": False, "message": "订单不存在"} - + if order.status != "paid": return {"success": False, "message": f"订单状态为{order.status},无法退款"} - + if order.refunded_at is not None: return {"success": False, "message": "订单已退款"} - refund_amount = refund_amount or order.amount - + refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount) + # 金额校验 - if refund_amount > order.amount: + if refund_amount > to_credit_decimal(order.amount): return {"success": False, "message": "退款金额超过订单金额"} - + # 根据支付方式调用相应的退款API db_configs = await _get_payment_configs(db) if order.payment_method == "alipay": @@ -1265,38 +1322,33 @@ async def process_refund( ) if not refund_result.get("success"): return refund_result - - # 扣除积分 + + # 按原支付业务位置适配新积分账本;不改变支付渠道退款流程。 try: - await deduct_credits( - db, - order.user_id, - order.credits, - refund_reason, - related_id=order.id, - biz_key=_payment_biz_key( - order, - charge_kind=CreditRecordChargeKind.REFUND.value, - action=CreditRecordAction.REFUND.value, - ), - refund_for_biz_key=_payment_biz_key( - order, - charge_kind=CreditRecordChargeKind.RECHARGE.value, - action=CreditRecordAction.CHARGE.value, - ), - record_meta=_payment_refund_meta(order), - ) + if order.product_id: + await revoke_payment_order_credits(db, order=order, reason=refund_reason) + else: + await deduct_credits( + db, + order.user_id, + order.credits, + refund_reason, + related_id=order.id, + biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value), + refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value), + record_meta=_payment_refund_meta(order), + ) except Exception as e: logger.exception(f"Failed to deduct credits for refund: {e}") return {"success": False, "message": "积分扣除失败"} - + # 更新订单状态 order.status = "refunded" order.refund_amount = refund_amount order.refunded_at = datetime.now() if order.payment_method == "alipay": order.refund_trade_no = db_configs.get("refund_trade_no", "") - + await db.commit() logger.info( f"REFUND_SUCCESS order_no={order_no} user={order.user_id} " @@ -1340,7 +1392,7 @@ async def _refund_alipay_order( request = AlipayTradeRefundRequest(biz_model=model) response_content = client.execute(request) - + if not response_content: logger.error(f"Alipay refund failed: empty response, order_no={order.order_no}") return {"success": False, "message": "支付宝退款响应为空"} diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py index bfe232f8..73734180 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType -from app.enums.llm_billing import LlmBillingConfigKey from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum from app.models.chat_generation_task import ChatGenerationTask from app.models.module_generation_project import ModuleGenerationProject @@ -59,13 +58,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod from app.services.llm import optimize_prompt from app.services.llm_billing import ( LlmBillingContext, - ensure_hold_exists, + ensure_pre_deducted, log_provider_failure, + record_provider_exception, log_provider_start, log_provider_success, - release_on_failure, - settle_success, - start_hold, + refund_on_final_failure, + finalize_llm_business_failure, + mark_business_success, + pre_deduct, ) from app.services.module_generation_flow_base_service import ( assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks, @@ -841,7 +842,7 @@ async def submit_image_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.error_message = None - await start_hold( + await pre_deduct( db, LlmBillingContext( user_id=str(project.user_id), @@ -855,9 +856,8 @@ async def submit_image_prompt_optimize( source_step_id=str(step.id), source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, related_id=str(step.id), - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, description_prefix="拆镜复刻图片AI提词优化", - trace_id=f"llm-submit-hold:{step.id}", + trace_id=f"llm-submit-pre-deduct:{step.id}", ), ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") @@ -954,14 +954,13 @@ async def run_image_prompt_optimize( source_step_id=step_id_value, source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, related_id=step_id_value, - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, - description_prefix="拆镜复刻图片AI提词优化", + description_prefix="拆镜复刻图片AI提词优化", trace_id=f"shot-image-prompt:{step_id_value}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -972,7 +971,7 @@ async def run_image_prompt_optimize( provider_succeeded = False token_usage: dict[str, Any] = {} - log_provider_start(llm_billing_context, detail={"prompt_type": "image"}) + await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"}) try: request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} log_module_prompt_event( @@ -997,9 +996,11 @@ async def run_image_prompt_optimize( log_owner_type="module_generation_step", log_owner_id=step_id_value, generation_attempt_no=expected_step_version, + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=token_usage) + await log_provider_success(db, llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1013,15 +1014,10 @@ async def run_image_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-图片AI提词优化(失效结果结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用") + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用") return None - billing = await settle_success( + billing = await mark_business_success( db, llm_billing_context, usage=token_usage, @@ -1069,21 +1065,19 @@ async def run_image_prompt_optimize( except DatabaseRowLockBusy: await db.rollback() if provider_succeeded: - # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-图片AI提词优化(行锁失败结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库") + await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库") return None # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() - if not provider_succeeded: - log_provider_failure(llm_billing_context, error=str(exc)) + if provider_succeeded: + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc) + if recovered_usage: + token_usage = recovered_usage if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1098,16 +1092,7 @@ async def run_image_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-图片AI提词优化(异常失效结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") - await db.commit() + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败") return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) if str(exc) else type(exc).__name__ @@ -1126,16 +1111,8 @@ async def run_image_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-图片AI提词优化(本地失败结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) return step @@ -1309,7 +1286,7 @@ async def submit_video_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.error_message = None - await start_hold( + await pre_deduct( db, LlmBillingContext( user_id=str(project.user_id), @@ -1323,9 +1300,8 @@ async def submit_video_prompt_optimize( source_step_id=str(step.id), source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, related_id=str(step.id), - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, description_prefix="拆镜复刻视频AI提词优化", - trace_id=f"llm-submit-hold:{step.id}", + trace_id=f"llm-submit-pre-deduct:{step.id}", ), ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") @@ -1420,14 +1396,13 @@ async def run_video_prompt_optimize( source_step_id=step_id_value, source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, related_id=step_id_value, - hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, - description_prefix="拆镜复刻视频AI提词优化", + description_prefix="拆镜复刻视频AI提词优化", trace_id=f"shot-video-prompt:{step_id_value}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -1438,7 +1413,7 @@ async def run_video_prompt_optimize( provider_succeeded = False token_usage: dict[str, Any] = {} - log_provider_start(llm_billing_context, detail={"prompt_type": "video"}) + await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"}) try: request_log = { "source_project_name": material.get("source_project_name") or "无", @@ -1475,9 +1450,11 @@ async def run_video_prompt_optimize( project_id=project_id_value, step_id=step_id_value, trace_id=f"shot-video-prompt:{step_id_value}", + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=token_usage) + await log_provider_success(db, llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1491,15 +1468,10 @@ async def run_video_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-视频AI提词优化(失效结果结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用") + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用") return None - billing = await settle_success( + billing = await mark_business_success( db, llm_billing_context, usage=token_usage, @@ -1550,21 +1522,19 @@ async def run_video_prompt_optimize( except DatabaseRowLockBusy: await db.rollback() if provider_succeeded: - # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-视频AI提词优化(行锁失败结算)", - ) - await db.commit() + await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库") + await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库") return None # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() - if not provider_succeeded: - log_provider_failure(llm_billing_context, error=str(exc)) + if provider_succeeded: + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc) + if recovered_usage: + token_usage = recovered_usage if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1579,16 +1549,7 @@ async def run_video_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-视频AI提词优化(异常失效结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") - await db.commit() + await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败") return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) if str(exc) else type(exc).__name__ @@ -1607,16 +1568,8 @@ async def run_video_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) - if provider_succeeded: - await settle_success( - db, - llm_billing_context, - usage=token_usage, - description="拆镜复刻-视频AI提词优化(本地失败结算)", - ) - else: - await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() + await finalize_llm_business_failure(llm_billing_context, error=str(exc)) return step @@ -1937,7 +1890,7 @@ async def mark_shot_replicate_step_dispatch_failed( project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = error_message if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value): - await release_on_failure( + await refund_on_final_failure( db, LlmBillingContext( user_id=str(project.user_id), @@ -1955,11 +1908,6 @@ async def mark_shot_replicate_step_dispatch_failed( source_step_id=str(step.id), source_step_code=str(step.step_code), related_id=str(step.id), - hold_config_key=( - LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value - if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value - else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value - ), description_prefix=( "拆镜复刻图片AI提词优化" if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value diff --git a/video-gen-api/app/services/shot_replicate_recovery_service.py b/video-gen-api/app/services/shot_replicate_recovery_service.py index ff828bb9..1c080eb9 100644 --- a/video-gen-api/app/services/shot_replicate_recovery_service.py +++ b/video-gen-api/app/services/shot_replicate_recovery_service.py @@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.celery_queue import CeleryQueue -from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState +from app.enums.llm_billing import LlmBillingLedgerState from app.enums.shot_replicate import ShotSplitStatusEnum from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.shot_replicate_task_set import ShotReplicateTaskSet @@ -19,7 +19,6 @@ from app.services.shot_replicate_taskset_service import ( refresh_task_set_split_summaries, ) from app.services.llm_billing import get_llm_ledger_states -from app.services.llm_billing.config import get_llm_billing_policy from app.services.celery_runtime.runtime_service import runtime_lock_values from app.tasks.celery_app import celery_app @@ -230,15 +229,11 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: ) segments = list(segment_result.scalars().all()) - billing_policy = await get_llm_billing_policy( - db, - config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - ) billing_contexts = [ *(build_task_set_analysis_billing_context(item) for item in task_sets), *(build_segment_analysis_billing_context(item) for item in segments), ] - # 配置关闭后仍需识别并继续处理已存在的 active HOLD;只有 missing 流水才按 bypass。 + # 固定预扣配置变更后仍需识别并继续处理已存在的有效预扣;缺少预扣记录时禁止恢复。 billing_states = await get_llm_ledger_states(db, billing_contexts) lock_keys: list[str] = [] @@ -264,13 +259,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1 continue context = build_task_set_analysis_billing_context(item) - validation = billing_states.get(context.hold_biz_key) + validation = billing_states.get(context.pre_deduct_biz_key) can_execute = bool(validation and validation.can_execute) state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value - if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed: - can_execute = True - state = LlmBillingLedgerState.BILLING_BYPASSED.value - elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid: + if validation and validation.state == LlmBillingLedgerState.MISSING: state = LlmBillingLedgerState.INVALID.value if not can_execute: item.analysis_status = ShotAnalysisStatusEnum.FAILED.value @@ -297,13 +289,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1 continue context = build_segment_analysis_billing_context(item) - validation = billing_states.get(context.hold_biz_key) + validation = billing_states.get(context.pre_deduct_biz_key) can_execute = bool(validation and validation.can_execute) state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value - if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed: - can_execute = True - state = LlmBillingLedgerState.BILLING_BYPASSED.value - elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid: + if validation and validation.state == LlmBillingLedgerState.MISSING: state = LlmBillingLedgerState.INVALID.value if not can_execute: item.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value diff --git a/video-gen-api/app/services/shot_replicate_taskset_service.py b/video-gen-api/app/services/shot_replicate_taskset_service.py index ea30cd4a..89b5d4b5 100644 --- a/video-gen-api/app/services/shot_replicate_taskset_service.py +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -16,7 +16,6 @@ from app.enums.credit_record import ( CreditRecordSourceModule, CreditRecordSourceStepCode, ) -from app.enums.llm_billing import LlmBillingConfigKey from sqlalchemy import String, case, cast, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -56,8 +55,8 @@ from app.schemas.shot_replicate import ( from app.services.module_generation_log_service import log_module_event_file from app.services.llm_billing import ( LlmBillingContext, - release_on_failure, - start_hold, + refund_on_final_failure, + pre_deduct, validate_retryable_previous_attempt, ) from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source @@ -94,8 +93,7 @@ def build_task_set_analysis_billing_context(task_set: ShotReplicateTaskSet) -> L source_step_id=str(task_set.id), source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, related_id=str(task_set.id), - hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - description_prefix="拆镜复刻原视频AI分析", + description_prefix="拆镜复刻原视频AI分析", trace_id=f"shot-task-set-analysis:{task_set.id}:attempt:{int(task_set.analysis_attempt_no or 1)}", ) @@ -113,8 +111,7 @@ def build_segment_analysis_billing_context(segment: ShotReplicateSegment) -> Llm source_step_id=str(segment.id), source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, related_id=str(segment.id), - hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - description_prefix="拆镜复刻片段视频AI分析", + description_prefix="拆镜复刻片段视频AI分析", trace_id=f"shot-segment-analysis:{segment.id}:attempt:{int(segment.analysis_attempt_no or 1)}", ) @@ -268,7 +265,7 @@ async def create_task_set( ) db.add(task_set) await db.flush() - await start_hold(db, build_task_set_analysis_billing_context(task_set)) + await pre_deduct(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type="SHOT_TASK_SET_CREATED", @@ -751,7 +748,7 @@ async def create_custom_segment( task_set.status = ShotTaskSetStatusEnum.SPLITTING.value task_set.split_status = ShotSplitStatusEnum.PROCESSING.value await db.flush() - await start_hold(db, build_segment_analysis_billing_context(segment)) + await pre_deduct(db, build_segment_analysis_billing_context(segment)) await refresh_task_set_split_summary(db, task_set.id) await db.flush() log_module_event_file( @@ -920,7 +917,7 @@ async def delete_segment( raise HTTPException(status_code=409, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除") if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: - await release_on_failure( + await refund_on_final_failure( db, build_segment_analysis_billing_context(segment), error="用户删除自定义拆镜片段,释放未结算的片段分析预扣", @@ -982,7 +979,7 @@ async def delete_segment( "pending_delete_resource_count": len(pending_delete_resource_ids), "physical_file_delete": "after_commit", "media_refund": False, - "llm_hold_release_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, + "llm_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, }, ) @@ -1037,14 +1034,14 @@ async def delete_task_set( # 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。 # 这里仅做账务补偿,不改变现有逐项目删除流程。 - await release_on_failure( + await refund_on_final_failure( db, build_task_set_analysis_billing_context(task_set), error="用户删除拆镜任务集,释放未结算的原视频分析预扣", ) for segment in segments: if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: - await release_on_failure( + await refund_on_final_failure( db, build_segment_analysis_billing_context(segment), error="用户删除拆镜任务集,释放未结算的片段分析预扣", @@ -1113,7 +1110,7 @@ async def delete_task_set( "segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"}, "physical_file_delete": "after_commit", "media_refund": False, - "llm_hold_release_on_cancel": True, + "llm_pre_deduct_refund_on_cancel": True, }, ) return ShotTaskSetDeleteOut( @@ -1198,7 +1195,7 @@ async def prepare_reanalyze_task_set( task_set.analysis_raw_json = None task_set.analysis_result_json = None await db.flush() - await start_hold(db, build_task_set_analysis_billing_context(task_set)) + await pre_deduct(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value, @@ -1304,7 +1301,7 @@ async def prepare_reanalyze_segment( segment.segment_category = None segment.segment_audience = None await db.flush() - await start_hold(db, build_segment_analysis_billing_context(segment)) + await pre_deduct(db, build_segment_analysis_billing_context(segment)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value, @@ -1357,7 +1354,7 @@ async def mark_task_set_analysis_dispatch_failed( task_set.analysis_claim_token = None task_set.analysis_lease_until = None task_set.analysis_error_message = error_message - await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message) + await refund_on_final_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, @@ -1394,7 +1391,7 @@ async def mark_segment_analysis_dispatch_failed( segment.analysis_claim_token = None segment.analysis_lease_until = None segment.analysis_error_message = error_message - await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message) + await refund_on_final_failure(db, build_segment_analysis_billing_context(segment), error=error_message) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, @@ -1423,7 +1420,7 @@ async def mark_custom_segment_split_dispatch_failed( segment.split_next_retry_at = None segment.split_last_error = error_message # 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用 - # 原 active HOLD;只有片段分析最终失败或用户删除片段时才释放。 + # 原固定预扣;只有片段分析最终失败或用户删除片段时才按原来源退款。 log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, diff --git a/video-gen-api/app/services/shot_video_analysis_service.py b/video-gen-api/app/services/shot_video_analysis_service.py index 255c5edf..de5fc542 100644 --- a/video-gen-api/app/services/shot_video_analysis_service.py +++ b/video-gen-api/app/services/shot_video_analysis_service.py @@ -17,6 +17,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url from app.enums.common import LogEventStatusEnum, LogSourceEnum from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum from app.services.operation_log_service import log_ai_model_event +from app.services.llm_billing.context import LlmProviderPostprocessError from app.utils.id_gen import generate_id AnalysisMode = Literal["full_breakdown", "summary_only"] @@ -411,13 +412,15 @@ def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode return result -async def _select_model_config(db: AsyncSession) -> ModelConfig | None: - result = await db.execute( - select(ModelConfig) - .where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)) - .order_by(ModelConfig.priority.desc()) - .limit(1) +async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None: + stmt = select(ModelConfig).where( + ModelConfig.is_active == True, + ModelConfig.deleted_at.is_(None), + ModelConfig.provider != "mock", ) + if model_config_id: + stmt = stmt.where(ModelConfig.id == model_config_id) + result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)) return result.scalar_one_or_none() @@ -559,6 +562,8 @@ async def analyze_video_for_shot_split( task_set_id: str | None = None, segment_id: str | None = None, trace_id: str | None = None, + fixed_model_config_id: str | None = None, + fixed_model_snapshot: dict[str, Any] | None = None, ) -> ShotVideoAnalysisResult: """调用模型完成拆镜/片段分析。 @@ -568,19 +573,20 @@ async def analyze_video_for_shot_split( """ trace_id = trace_id or generate_id() call_id = generate_id() - config_row = await _select_model_config(db) + config_row = await _select_model_config(db, fixed_model_config_id) if not config_row: raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型") # 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。 + snapshot = dict(fixed_model_snapshot or {}) config = SimpleNamespace( id=str(config_row.id), - name=str(config_row.name or ""), - provider=str(config_row.provider or ""), - api_base=str(config_row.api_base or ""), + name=str(snapshot.get("name") or config_row.name or ""), + provider=str(snapshot.get("provider") or config_row.provider or ""), + api_base=str(snapshot.get("api_base") or config_row.api_base or ""), api_key=str(config_row.api_key or ""), - model_name=str(config_row.model_name or ""), - max_tokens=getattr(config_row, "max_tokens", None), - temperature=getattr(config_row, "temperature", None), + model_name=str(snapshot.get("model_name") or config_row.model_name or ""), + max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else getattr(config_row, "max_tokens", None), + temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else getattr(config_row, "temperature", None), ) if not str(config.api_key or "").strip(): raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}") @@ -610,8 +616,8 @@ async def analyze_video_for_shot_split( {"role": "system", "content": system_prompt}, user_message, ], - "max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000), - "temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1), + "max_tokens": int((getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or 5000)), + "temperature": float((getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or 0.1)), "response_format": {"type": "json_object"}, } log_request_data: dict[str, Any] = { @@ -729,33 +735,6 @@ async def analyze_video_for_shot_split( ) raise - try: - content = get_message_content_or_raise(raw) - result = parse_model_json(content) - except Exception as exc: - event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value - _log_shot_ai_model_event( - call_id=call_id, - event_type=event_type, - event_status=LogEventStatusEnum.FAILED.value, - config=config, - trace_id=trace_id, - user_id=user_id, - task_set_id=task_set_id, - segment_id=segment_id, - mode=mode, - request_data={**log_request_data, "api_url": url}, - response_data=raw, - http_status=response.status_code, - message="拆镜视频分析模型内容解析失败", - error=str(exc), - ) - raise - - result = fill_none_with_wu(result) - result = ensure_result_schema(result) - result = filter_and_normalize_breakdown(result, mode=mode) - raw_usage = raw.get("usage") usage_reported = bool( isinstance(raw_usage, dict) @@ -777,6 +756,8 @@ async def analyze_video_for_shot_split( "analysis_mode": mode, "trace_id": trace_id, "usage_reported": usage_reported, + "provider_request_id": str(raw.get("id") or remote_request_id or "") or None, + "http_status": int(response.status_code), } if not token_usage["total_tokens"]: token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"] @@ -788,6 +769,33 @@ async def analyze_video_for_shot_split( "model_name": config.model_name, }) + try: + content = get_message_content_or_raise(raw) + result = parse_model_json(content) + result = fill_none_with_wu(result) + result = ensure_result_schema(result) + result = filter_and_normalize_breakdown(result, mode=mode) + except Exception as exc: + event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value + _log_shot_ai_model_event( + call_id=call_id, + event_type=event_type, + event_status=LogEventStatusEnum.FAILED.value, + config=config, + trace_id=trace_id, + user_id=user_id, + task_set_id=task_set_id, + segment_id=segment_id, + mode=mode, + request_data={**log_request_data, "api_url": url}, + response_data=raw, + token_usage=token_usage, + http_status=response.status_code, + message="拆镜视频分析模型内容后处理失败", + error=str(exc), + ) + raise LlmProviderPostprocessError(f"拆镜视频分析响应后处理失败: {exc}", usage=token_usage) from exc + _log_shot_ai_model_event( call_id=call_id, event_type=( diff --git a/video-gen-api/app/services/team_manager_service.py b/video-gen-api/app/services/team_manager_service.py index c414586f..0067baff 100644 --- a/video-gen-api/app/services/team_manager_service.py +++ b/video-gen-api/app/services/team_manager_service.py @@ -1,25 +1,15 @@ from __future__ import annotations -from datetime import datetime, timezone from typing import Any from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.enums.credit_record import ( - CreditRecordBillingScene, - CreditRecordChargeKind, - CreditRecordSubject, - CreditRecordSourceModule, -) -from app.enums.team import TeamStatus from app.enums.user import UserType from app.models.team import Team from app.models.user import User -from app.services.credits import add_credits, deduct_credits -from app.services.credit_record_meta_service import CreditRecordMeta -from app.utils.id_gen import generate_id +from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team: @@ -114,6 +104,9 @@ async def get_team_members( .limit(page_size) ) members = list(result.scalars().all()) + credit_map = await get_user_credit_map(db, [item.id for item in members]) + for item in members: + attach_credit_snapshot(item, credit_map.get(item.id, 0.0)) return { "items": [ @@ -139,104 +132,4 @@ async def transfer_credits_to_member( direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减 description: str | None = None, ) -> None: - """管理人为团队成员增加或扣减积分。 - - direction: - - "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加) - - "decrease": 从成员扣积分回到管理人(成员减少,管理人增加) - """ - if amount <= 0: - raise HTTPException(status_code=400, description="积分数量必须大于0") - if direction not in ("increase", "decrease"): - raise HTTPException(status_code=400, description="无效操作方向") - - # 获取管理人 - manager_result = await db.execute( - select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1) - ) - manager = manager_result.scalar_one_or_none() - if not manager: - raise HTTPException(status_code=404, detail="管理员不存在") - - # 获取目标成员 - member_result = await db.execute( - select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1) - ) - member = member_result.scalar_one_or_none() - if not member: - raise HTTPException(status_code=404, detail="成员不存在") - - # 验证管理人是该团队的管理人且目标是同团队成员 - if not manager.team_id: - raise HTTPException(status_code=400, detail="您不在任何团队中") - if member.team_id != manager.team_id: - raise HTTPException(status_code=400, detail="只能操作同团队成员") - - team_result = await db.execute( - select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1) - ) - team = team_result.scalar_one_or_none() - if not team or team.manager_id != manager_id: - raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分") - - # 禁止管理人给自己转积分 - if target_member_id == manager_id: - raise HTTPException(status_code=400, detail="不能给自己调整积分") - - desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减") - xfer_id = generate_id() - - # 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置 - def _build_team_transfer_meta(uid: str) -> CreditRecordMeta: - meta = CreditRecordMeta( - owner_type="team_internal_transfer", - owner_id=xfer_id, - charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value, - credit_subject=CreditRecordSubject.TEAM_INTERNAL.value, - source_module=CreditRecordSourceModule.TEAM.value, - billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value, - ) - return meta - - if direction == "increase": - # 管理人扣减 - await deduct_credits( - db, - manager_id, - amount, - f"分配给成员 {member.username}: {desc}", - record_type="team_internal", - biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}", - record_meta=_build_team_transfer_meta(manager_id), - ) - # 成员增加 - await add_credits( - db, - target_member_id, - amount, - f"来自团队管理人: {desc}", - record_type="team_internal", - biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}", - record_meta=_build_team_transfer_meta(target_member_id), - ) - else: - # 成员扣减 - await deduct_credits( - db, - target_member_id, - amount, - f"扣减给团队管理人: {desc}", - record_type="team_internal", - biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}", - record_meta=_build_team_transfer_meta(target_member_id), - ) - # 管理人增加 - await add_credits( - db, - manager_id, - amount, - f"来自成员 {member.username}: {desc}", - record_type="team_internal", - biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}", - record_meta=_build_team_transfer_meta(manager_id), - ) + raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能") diff --git a/video-gen-api/app/tasks/celery_app.py b/video-gen-api/app/tasks/celery_app.py index 0425513f..3e1264f9 100644 --- a/video-gen-api/app/tasks/celery_app.py +++ b/video-gen-api/app/tasks/celery_app.py @@ -34,6 +34,7 @@ CELERY_TASK_IMPORTS = ( "app.tasks.module_generation_v2_tasks", "app.tasks.private_portrait_asset_tasks", "app.tasks.celery_runtime_tasks", + "app.tasks.credit_tasks", ) @@ -120,6 +121,11 @@ def _beat_schedule() -> dict: "schedule": 300, "options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value}, } + schedule["credit-maintenance-every-minute"] = { + "task": CeleryTaskName.CREDIT_MAINTENANCE.value, + "schedule": 60, + "options": {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value}, + } return schedule @@ -181,6 +187,7 @@ if broker_url: CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"ignore_result": True}, CeleryTaskName.SHOT_ANALYSIS_RECOVERY.value: {"ignore_result": True}, CeleryTaskName.SHOT_SPLIT_RECOVERY.value: {"ignore_result": True}, + CeleryTaskName.CREDIT_MAINTENANCE.value: {"ignore_result": True}, }, worker_prefetch_multiplier=1, worker_cancel_long_running_tasks_on_connection_loss=True, @@ -240,6 +247,7 @@ if broker_url: CeleryTaskName.PRIVATE_PORTRAIT_DELETE_GROUP.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value}, CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value}, CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value}, + CeleryTaskName.CREDIT_MAINTENANCE.value: {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value}, }, ) else: diff --git a/video-gen-api/app/tasks/credit_tasks.py b/video-gen-api/app/tasks/credit_tasks.py new file mode 100644 index 00000000..aff3ff10 --- /dev/null +++ b/video-gen-api/app/tasks/credit_tasks.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Any + +from app.models.base import async_session +from app.services.credit.expiration_service import ( + archive_expired_user_balances, + list_expired_balance_user_limits, +) +from app.services.credit.subscription_service import ( + expire_subscription_by_id, + grant_due_subscription_period_by_id, + list_due_subscription_ids, + list_due_subscription_period_candidates, +) +from app.services.credit.utils import utc_now +from app.services.operation_log_service import log_operation_event +from app.tasks.async_runner import run_async +from app.tasks.celery_app import celery_app + + +async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]: + checked_at = utc_now() + limit = max(1, min(int(batch_size or 500), 2000)) + errors: list[dict[str, str]] = [] + log_operation_event( + domain="billing", + module="credit_maintenance", + event_type="CREDIT_MAINTENANCE_STARTED", + event_status="started", + source="app.tasks.credit_tasks._run_credit_maintenance_once", + message="积分维护批次开始", + detail={"checked_at": checked_at.isoformat(), "batch_size": limit}, + ) + + async with async_session() as scan_db: + grant_candidates = await list_due_subscription_period_candidates( + scan_db, request_time=checked_at, batch_size=limit + ) + subscription_ids = await list_due_subscription_ids( + scan_db, request_time=checked_at, batch_size=limit + ) + expired_user_limits = await list_expired_balance_user_limits( + scan_db, request_time=checked_at, batch_size=limit + ) + await scan_db.rollback() + + def record_failure(stage: str, item_id: str, exc: Exception) -> None: + errors.append({"stage": stage, "id": item_id, "error": str(exc)}) + log_operation_event( + domain="billing", + module="credit_maintenance", + event_type="CREDIT_MAINTENANCE_ITEM_FAILED", + event_status="failed", + source="app.tasks.credit_tasks._run_credit_maintenance_once", + task_id=item_id, + message="积分维护单条处理失败", + error=str(exc), + detail={"stage": stage, "item_id": item_id}, + ) + + granted = 0 + for period_id, subscription_id, user_id in grant_candidates: + async with async_session() as db: + try: + changed = await grant_due_subscription_period_by_id( + db, + period_id=period_id, + subscription_id=subscription_id, + user_id=user_id, + request_time=checked_at, + ) + await db.commit() + granted += int(changed) + except Exception as exc: + await db.rollback() + record_failure("subscription_grant", period_id, exc) + + expired_subscriptions = 0 + for subscription_id in subscription_ids: + async with async_session() as db: + try: + changed = await expire_subscription_by_id( + db, subscription_id=subscription_id, request_time=checked_at + ) + await db.commit() + expired_subscriptions += int(changed) + except Exception as exc: + await db.rollback() + record_failure("subscription_expire", subscription_id, exc) + + expired = 0 + for user_id, user_limit in expired_user_limits: + async with async_session() as db: + try: + changed = await archive_expired_user_balances( + db, user_id=user_id, request_time=checked_at, limit=user_limit + ) + await db.commit() + expired += int(changed) + except Exception as exc: + await db.rollback() + record_failure("credit_expire", user_id, exc) + + result = { + "checked_at": checked_at.isoformat(), + "subscription_periods_granted": granted, + "subscriptions_expired": expired_subscriptions, + "credit_balances_archived": expired, + "failed_count": len(errors), + "errors": errors[:50], + } + log_operation_event( + domain="billing", + module="credit_maintenance", + event_type="CREDIT_MAINTENANCE_COMPLETED", + event_status="failed" if errors else "success", + source="app.tasks.credit_tasks._run_credit_maintenance_once", + message="积分维护批次完成", + detail=result, + error=(f"{len(errors)} 条处理失败" if errors else None), + ) + return result + + +if celery_app: + + @celery_app.task( + name="credit.maintenance_once", + bind=True, + soft_time_limit=540, + time_limit=600, + ignore_result=True, + ) + def credit_maintenance_once(self, batch_size: int = 500) -> dict[str, Any]: + return run_async(_run_credit_maintenance_once(batch_size)) + +else: + + class _DisabledTask: + def delay(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("Celery is disabled") + + def apply_async(self, *args: Any, **kwargs: Any) -> None: + raise RuntimeError("Celery is disabled") + + credit_maintenance_once = _DisabledTask() diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py index ab7372dd..3d060b7d 100644 --- a/video-gen-api/app/tasks/shot_replicate_tasks.py +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -9,7 +9,6 @@ from sqlalchemy import select, update from app.config import settings from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSourceStepCode -from app.enums.llm_billing import LlmBillingConfigKey from app.enums.celery_queue import CeleryQueue, CeleryTaskName from app.enums.celery_runtime import CeleryRuntimeDomain from app.enums.shot_replicate import ( @@ -40,19 +39,19 @@ from app.services.shot_replicate_taskset_service import ( from app.services.shot_video_analysis_service import analyze_video_for_shot_split from app.services.llm_billing import ( LlmBillingContext, - ensure_hold_exists, + ensure_pre_deducted, log_provider_failure, log_provider_start, log_provider_success, - release_on_failure, - settle_success, + mark_business_success, + record_provider_exception, + finalize_llm_business_failure, ) from app.services.shot_video_split_service import cleanup_split_result, finalize_split_result, split_video_segment_async from app.services.upload_video_asset_service import validate_split_range from app.services.upload_resource import record_shot_segment_upload_resource from app.tasks.async_runner import run_async from app.tasks.celery_app import celery_app -from app.utils.exceptions import InsufficientCreditsError logger = logging.getLogger("video_gen") @@ -170,7 +169,7 @@ async def _persist_original_analysis_result( billing_context: LlmBillingContext, allow_business_write: bool, ) -> str: - """持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。""" + """持久化原视频分析结果;失效结果按最终失败退款,禁止错误结算成功。""" async with async_session() as db: result = await db.execute( select(ShotReplicateTaskSet) @@ -190,32 +189,35 @@ async def _persist_original_analysis_result( and str(task_set.video_url) == str(video_url) and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value ) - if is_current and task_set is not None: - result_json = analyzed.result - task_set.original_video_content = str(result_json.get("原视频内容") or "无") - task_set.original_video_category = str(result_json.get("原视频分类") or "无") - task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无") - task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or [] - task_set.analysis_raw_json = analyzed.raw_response - task_set.analysis_result_json = result_json - task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value - task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value - task_set.analysis_claim_token = None - task_set.analysis_lease_until = None - task_set.analysis_error_message = None - description = "拆镜复刻-原视频分析" - outcome = "completed" - else: - description = "拆镜复刻-原视频分析(失效结果结算)" - outcome = "stale_settled" - await settle_success( + if not is_current or task_set is None: + await db.rollback() + await log_provider_failure(db, billing_context, error="原视频分析业务 attempt 已失效,供应商结果被丢弃") + await finalize_llm_business_failure( + billing_context, + error="原视频分析业务 attempt 已失效,供应商结果被丢弃", + ) + return "stale_refunded" + + result_json = analyzed.result + task_set.original_video_content = str(result_json.get("原视频内容") or "无") + task_set.original_video_category = str(result_json.get("原视频分类") or "无") + task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无") + task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or [] + task_set.analysis_raw_json = analyzed.raw_response + task_set.analysis_result_json = result_json + task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value + task_set.analysis_claim_token = None + task_set.analysis_lease_until = None + task_set.analysis_error_message = None + await mark_business_success( db, billing_context, usage=analyzed.usage, - description=description, + description="拆镜复刻-原视频分析", ) await db.commit() - return outcome + return "completed" async def _persist_segment_analysis_result( @@ -228,7 +230,7 @@ async def _persist_segment_analysis_result( billing_context: LlmBillingContext, allow_business_write: bool, ) -> str: - """持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。""" + """持久化切片分析结果;失效结果按最终失败退款,禁止错误结算成功。""" async with async_session() as db: result = await db.execute( select(ShotReplicateSegment) @@ -248,103 +250,102 @@ async def _persist_segment_analysis_result( and str(segment.segment_video_url) == str(video_url) and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value ) - if is_current and segment is not None: - result_json = analyzed.result - segment.original_video_content = str(result_json.get("原视频内容") or "无") - segment.original_video_category = str(result_json.get("原视频分类") or "无") - segment.original_video_audience = str(result_json.get("原视频受众人群") or "无") - segment.segment_content = segment.original_video_content - segment.segment_category = segment.original_video_category - segment.segment_audience = segment.original_video_audience - segment.analysis_json = result_json - segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value - segment.analysis_claim_token = None - segment.analysis_lease_until = None - segment.analysis_error_message = None - description = "拆镜复刻-片段视频分析" - outcome = "completed" - else: - description = "拆镜复刻-片段视频分析(失效结果结算)" - outcome = "stale_settled" - await settle_success( + if not is_current or segment is None: + await db.rollback() + await log_provider_failure(db, billing_context, error="片段视频分析业务 attempt 已失效,供应商结果被丢弃") + await finalize_llm_business_failure( + billing_context, + error="片段视频分析业务 attempt 已失效,供应商结果被丢弃", + ) + return "stale_refunded" + + result_json = analyzed.result + segment.original_video_content = str(result_json.get("原视频内容") or "无") + segment.original_video_category = str(result_json.get("原视频分类") or "无") + segment.original_video_audience = str(result_json.get("原视频受众人群") or "无") + segment.segment_content = segment.original_video_content + segment.segment_category = segment.original_video_category + segment.segment_audience = segment.original_video_audience + segment.analysis_json = result_json + segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value + segment.analysis_claim_token = None + segment.analysis_lease_until = None + segment.analysis_error_message = None + await mark_business_success( db, billing_context, usage=analyzed.usage, - description=description, + description="拆镜复刻-片段视频分析", ) await db.commit() - return outcome + return "completed" -async def _mark_original_provider_success_pending_manual( +async def _finalize_original_analysis_failure( *, task_set_id: str, attempt_no: int, token: str, - error_message: str, -) -> bool: - """供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。""" + billing_context: LlmBillingContext, + error: str, +) -> None: async with async_session() as db: result = await db.execute( select(ShotReplicateTaskSet) - .where( - ShotReplicateTaskSet.id == task_set_id, - ShotReplicateTaskSet.deleted_at.is_(None), - ) + .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) .with_for_update() .limit(1) ) task_set = result.scalar_one_or_none() - if not ( + if ( task_set and int(task_set.analysis_attempt_no or 1) == attempt_no and task_set.analysis_claim_token == token and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value ): - await db.rollback() - return False - task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value - task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value - task_set.analysis_claim_token = None - task_set.analysis_lease_until = None - task_set.analysis_error_message = error_message + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value + task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value + task_set.analysis_claim_token = None + task_set.analysis_lease_until = None + task_set.analysis_error_message = str(error)[:1000] await db.commit() - return True + await finalize_llm_business_failure(billing_context, error=error) -async def _mark_segment_provider_success_pending_manual( +async def _finalize_segment_analysis_failure( *, segment_id: str, attempt_no: int, token: str, - error_message: str, -) -> bool: - """供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。""" + billing_context: LlmBillingContext, + error: str, +) -> tuple[str | None, str | None]: + user_id: str | None = None + task_set_id: str | None = None async with async_session() as db: result = await db.execute( select(ShotReplicateSegment) - .where( - ShotReplicateSegment.id == segment_id, - ShotReplicateSegment.deleted_at.is_(None), - ) + .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) .with_for_update() .limit(1) ) segment = result.scalar_one_or_none() - if not ( + if segment: + user_id = str(segment.user_id) + task_set_id = str(segment.task_set_id) + if ( segment and int(segment.analysis_attempt_no or 1) == attempt_no and segment.analysis_claim_token == token and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value ): - await db.rollback() - return False - segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value - segment.analysis_claim_token = None - segment.analysis_lease_until = None - segment.analysis_error_message = error_message + segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value + segment.analysis_claim_token = None + segment.analysis_lease_until = None + segment.analysis_error_message = str(error)[:1000] await db.commit() - return True + await finalize_llm_business_failure(billing_context, error=error) + return user_id, task_set_id async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None: @@ -470,13 +471,12 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int source_step_id=task_set_id, source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, related_id=task_set_id, - hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - description_prefix="拆镜复刻原视频分析", + description_prefix="拆镜复刻原视频分析", trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: - error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止原视频分析任务" + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: + error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务" task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value task_set.analysis_claim_token = None @@ -511,7 +511,7 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int provider_succeeded = False analyzed = None - log_provider_start( + await log_provider_start(db, llm_billing_context, detail={"analysis_mode": "full_breakdown", "video_url": video_url}, ) @@ -523,9 +523,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int mode="full_breakdown", task_set_id=task_set_id, trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}", + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=analyzed.usage) + await log_provider_success(db, llm_billing_context, usage=analyzed.usage) try: await lease.ensure_owned() allow_business_write = True @@ -566,8 +568,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int except RedisExecutionLockError: raise except Exception as exc: - if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): - log_provider_failure(llm_billing_context, error=str(exc)) + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False): + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc) if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None: try: try: @@ -595,40 +600,16 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int ) return except Exception as settlement_exc: - manual_error = ( - "供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分," - f"需人工对账。error={settlement_exc}" - ) - await _mark_original_provider_success_pending_manual( - task_set_id=task_set_id, - attempt_no=attempt_no, - token=token, - error_message=manual_error, - ) + await log_provider_failure(db, llm_billing_context, error=str(settlement_exc)) exc = settlement_exc - elif "llm_billing_context" in locals(): - async with async_session() as db: - result = await db.execute( - select(ShotReplicateTaskSet) - .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None)) - .with_for_update() - .limit(1) - ) - task_set = result.scalar_one_or_none() - if ( - task_set - and int(task_set.analysis_attempt_no or 1) == attempt_no - and task_set.analysis_claim_token == token - and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value - ): - task_set_user_id = task_set_user_id or str(task_set.user_id) - task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value - task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value - task_set.analysis_claim_token = None - task_set.analysis_lease_until = None - task_set.analysis_error_message = str(exc) - await release_on_failure(db, llm_billing_context, error=str(exc)) - await db.commit() + if "llm_billing_context" in locals(): + await _finalize_original_analysis_failure( + task_set_id=task_set_id, + attempt_no=attempt_no, + token=token, + billing_context=llm_billing_context, + error=str(exc), + ) log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value, @@ -762,13 +743,12 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no source_step_id=segment_id, source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, related_id=segment_id, - hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, - description_prefix="拆镜复刻片段视频分析", + description_prefix="拆镜复刻片段视频分析", trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", ) - hold_validation = await ensure_hold_exists(db, llm_billing_context) - if not hold_validation.can_execute: - error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止片段视频分析任务" + pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) + if not pre_deduct_validation.can_execute: + error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务" segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value segment.analysis_claim_token = None segment.analysis_lease_until = None @@ -798,7 +778,7 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no provider_succeeded = False analyzed = None - log_provider_start( + await log_provider_start(db, llm_billing_context, detail={"analysis_mode": "summary_only", "video_url": video_url}, ) @@ -811,9 +791,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no task_set_id=task_set_id, segment_id=segment_id, trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", + fixed_model_config_id=llm_billing_context.model_config_id, + fixed_model_snapshot=llm_billing_context.model_parameters_snapshot, ) provider_succeeded = True - log_provider_success(llm_billing_context, usage=analyzed.usage) + await log_provider_success(db, llm_billing_context, usage=analyzed.usage) try: await lease.ensure_owned() allow_business_write = True @@ -854,8 +836,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no except RedisExecutionLockError: raise except Exception as exc: - if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): - log_provider_failure(llm_billing_context, error=str(exc)) + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False): + await log_provider_failure(db, llm_billing_context, error=str(exc)) + else: + provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc) if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None: try: try: @@ -884,40 +869,18 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no ) return except Exception as settlement_exc: - manual_error = ( - "供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分," - f"需人工对账。error={settlement_exc}" - ) - await _mark_segment_provider_success_pending_manual( - segment_id=segment_id, - attempt_no=attempt_no, - token=token, - error_message=manual_error, - ) + await log_provider_failure(db, llm_billing_context, error=str(settlement_exc)) exc = settlement_exc - elif "llm_billing_context" in locals(): - async with async_session() as db: - result = await db.execute( - select(ShotReplicateSegment) - .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None)) - .with_for_update() - .limit(1) - ) - segment = result.scalar_one_or_none() - if ( - segment - and int(segment.analysis_attempt_no or 1) == attempt_no - and segment.analysis_claim_token == token - and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value - ): - user_id = user_id or str(segment.user_id) - task_set_id = task_set_id or str(segment.task_set_id) - segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value - segment.analysis_claim_token = None - segment.analysis_lease_until = None - segment.analysis_error_message = str(exc) - await release_on_failure(db, llm_billing_context, error=str(exc)) - await db.commit() + if "llm_billing_context" in locals(): + found_user_id, found_task_set_id = await _finalize_segment_analysis_failure( + segment_id=segment_id, + attempt_no=attempt_no, + token=token, + billing_context=llm_billing_context, + error=str(exc), + ) + user_id = user_id or found_user_id + task_set_id = task_set_id or found_task_set_id log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value, @@ -984,20 +947,17 @@ async def _mark_auto_segment_analysis_dispatch_failed( segment.analysis_started_at = None segment.analysis_lease_until = None segment.analysis_error_message = error_message - await release_on_failure( - db, - build_segment_analysis_billing_context(segment), - error=error_message, - ) + billing_context = build_segment_analysis_billing_context(segment) await db.commit() + await finalize_llm_business_failure(billing_context, error=error_message) log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, project_id=task_set_id, step_id=segment_id, user_id=user_id, - message="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分", + message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分", detail={ "segment_id": segment_id, "task_set_id": task_set_id, @@ -1256,7 +1216,7 @@ async def _run_split_one_segment(segment_id: str) -> None: segment.split_status = ShotSplitStatusEnum.FAILED.value segment.split_next_retry_at = None final_failed = True - # 切片最终失败不释放片段分析 HOLD;手动切片重试继续沿用 + # 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用 # 原 attempt。用户最终删除片段/任务集时再做取消补偿。 else: segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 4f7d81fb..1a9a9246 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -280,10 +280,29 @@ export async function retryGeneration(recordId: string): Promise(`/generation-records/${recordId}/retry`); } // ── Credits ─────────────────────────────────────────────── -export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> { +export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; availableCredits: number; nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null; totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number }> { if (USE_MOCK) { const data = await mock.mockGetCredits(); - return data; + const records = data.records || []; + const totalGranted = records + .filter((record) => record.type === 'recharge' || record.type === 'refund') + .reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0); + const totalConsumed = records + .filter((record) => record.type === 'consume') + .reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0); + return { + credits: data.credits, + availableCredits: data.credits, + nextExpiringCredits: 0, + nextExpiresAt: null, + nextLastUsableAt: null, + totalGranted, + totalConsumed, + totalRefunded: 0, + totalExpired: 0, + records, + total: data.total, + }; } const params = new URLSearchParams(); params.set('page', String(page)); @@ -301,7 +320,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise { +export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> { if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' }; return api.get('/auth/site-info', false); } @@ -542,7 +561,7 @@ export async function getone(projectId: string, stepId: string): Promise { export async function updateImagePrompt(projectId: string, stepId: string, params: any): Promise { return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/image-prompt`, params); } - + // 第二步,生成图片 export async function gettwo(projectId: string, stepId: string ,params: any): Promise { return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params); @@ -915,7 +934,7 @@ export async function deleteOAuthAccount(params: DeleteOAuthAccountParams): Prom export async function getAllOAuthAccountList(): Promise { return api.post(`/upload-material/oauth_account_list`); } -// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id +// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id export async function submitPreTest(params: { resources_material_ids: any[]; pre_test_template_id: string }): Promise { return api.post(`/resources-material/pre-commit`, params); } @@ -1263,3 +1282,14 @@ export function getTeamCreditExportUrl(params: { const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000'; return `${base}/api/team/credit-records/export?${p.toString()}`; } + +// ── Dynamic credit products ─────────────────────────────── +export async function getCreditProductCatalog(): Promise { + return api.get('/credit-products/catalog'); +} + +export async function getCreditBalances(page = 1, pageSize = 20, status?: string): Promise { + const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) }); + if (status) params.set('status', status); + return api.get(`/credits/balances?${params.toString()}`); +} diff --git a/video-gen-app/src/api/mock.ts b/video-gen-app/src/api/mock.ts index 5a2ed396..5c479178 100644 --- a/video-gen-app/src/api/mock.ts +++ b/video-gen-app/src/api/mock.ts @@ -207,10 +207,14 @@ export async function mockOptimizePrompt( await delay(1500); const project = MOCK_PROJECTS.find((p) => p.id === projectId); - const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2); + // Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。 + const cost = 5; if (currentUser) { - currentUser.credits -= cost; + if (currentUser.credits < cost) { + throw new Error('积分不足'); + } + currentUser.credits = Math.round((currentUser.credits - cost) * 100) / 100; } const optimizedPromptMap: Record = { @@ -356,7 +360,11 @@ export async function mockGetAdminUsers(search?: string): Promise { export async function mockAdjustCredits(userId: string, amount: number, _description: string): Promise { await delay(500); const user = MOCK_ADMIN_USERS.find(u => u.id === userId); - if (user) user.credits += amount; + if (user) { + const nextCredits = Math.round((user.credits + amount) * 100) / 100; + if (nextCredits < 0) throw new Error('有效积分不足'); + user.credits = nextCredits; + } } export async function mockToggleUserStatus(userId: string, isActive: boolean): Promise { diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 814a442b..3972b095 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -65,7 +65,8 @@ import { } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../store/useAuthStore'; -import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api'; +import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api'; +import type { CreditProduct, CreditProductCatalog } from '../../types'; import NotificationPopup from '../NotificationPopup'; import './AppLayout.css'; import bg1 from '../../assets/bg1.png'; @@ -319,79 +320,33 @@ const MEMBERSHIP_GRADIENTS = [ { gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: }, ]; -const MEMBERSHIP_FEATURES = [ - '每日赠送积分 当日清零', - '可充值更多积分', - '基础加速通道', - '作品去除水印', - '可使用网页版和APP上全部AI功能', - '高分辨率', - '批量创作 更多同时生成任务', -]; - -const MEMBERSHIP_PLANS = [ - { - tier: '基础会员', - desc: '开启创作之旅', - monthlyPrice: 79, - monthlyCredits: 1000, - onlyMonthly: true, - features: MEMBERSHIP_FEATURES.map((f) => f.replace('基础加速通道', '基础加速通道')), - speedLabel: '基础加速通道', - }, - { - tier: '标准会员', - desc: '畅享基础创作权益', - monthlyPrice: 154, - monthlyCredits: 2000, - quarterlyPrice: 1029, - quarterlyCredits: 5000, - yearlyPrice: 7308, - yearlyCredits: 10000, - features: MEMBERSHIP_FEATURES, - speedLabel: '标准加速通道', - }, - { - tier: '高级会员', - desc: '解锁高级创作权益', - monthlyPrice: 221, - monthlyCredits: 3000, - quarterlyPrice: 1197, - quarterlyCredits: 6000, - yearlyPrice: 10710, - yearlyCredits: 15000, - features: MEMBERSHIP_FEATURES, - speedLabel: '高级加速通道', - }, - { - tier: '超级会员', - desc: '释放无限创作生产力', - monthlyPrice: 280, - monthlyCredits: 4000, - quarterlyPrice: 1367, - quarterlyCredits: 7000, - yearlyPrice: 13440, - yearlyCredits: 20000, - features: MEMBERSHIP_FEATURES, - speedLabel: '高级加速通道', - hot: true, - }, -]; - const AppLayout: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, logout, refreshUser, setOptimizeHoldCredits } = useAuthStore(); + const { user, logout, refreshUser } = useAuthStore(); const [pwdModalOpen, setPwdModalOpen] = useState(false); const [rechargeModalOpen, setRechargeModalOpen] = useState(false); const [contactModalOpen, setContactModalOpen] = useState(false); const [pwdForm] = Form.useForm(); const [usernameForm] = Form.useForm(); - const [selectedPlan, setSelectedPlan] = useState(null); + const [selectedPlan, setSelectedPlan] = useState(null); const [menuItems, setMenuItems] = useState([]); - const [rechargeOptions, setRechargeOptions] = useState([]); + const [rechargeOptions, setRechargeOptions] = useState([]); + const [subscriptionProducts, setSubscriptionProducts] = useState([]); + const [currentSubscription, setCurrentSubscription] = useState(null); + const loadCreditCatalog = useCallback(async () => { + try { + const data = await getCreditProductCatalog(); + setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive)); + setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive)); + setCurrentSubscription(data.currentSubscription || null); + } catch { + setSubscriptionProducts([]); + setRechargeOptions([]); + setCurrentSubscription(null); + } + }, []); const [creditsModalOpen, setCreditsModalOpen] = useState(false); - const [selectedTierIndex, setSelectedTierIndex] = useState(null); const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly'); const [unreadCount, setUnreadCount] = useState(0); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -483,10 +438,6 @@ const AppLayout: React.FC = () => { setOperationManualUrl(info.operationManual); } - if (info.optimizeHoldCredits !== undefined) { - setOptimizeHoldCredits(info.optimizeHoldCredits); - } else { - } localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo })); }).catch((err) => { @@ -625,15 +576,13 @@ const AppLayout: React.FC = () => { const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false); setMenuItems(items); }).catch(() => { }); - getRechargePackages().then(data => { - setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false)); - }).catch(() => { }); + loadCreditCatalog(); getPaymentMethods().then(data => { setEnabledMethods(data); if (data.alipay) setPaymentMethod('alipay'); else if (data.wechat) setPaymentMethod('wechat'); }).catch(() => { }); - }, []); + }, [loadCreditCatalog]); useEffect(() => { if (user) { @@ -817,6 +766,7 @@ const AppLayout: React.FC = () => { localStorage.removeItem(PENDING_ORDER_KEY); message.success('支付成功!积分已到账'); useAuthStore.getState().refreshUser(); + loadCreditCatalog(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setSelectedPlan(null); @@ -849,7 +799,7 @@ const AppLayout: React.FC = () => { }); }, 1000); countdownTimerRef.current = countdownTimer; - }, [stopPolling]); + }, [loadCreditCatalog, stopPolling]); const renderMenuItem = (item: MenuConfig, depth: number = 0) => { const isActive = item.path === selectedKey; @@ -1077,22 +1027,22 @@ const AppLayout: React.FC = () => {