diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 8a65219c..641d0cdf 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -1328,7 +1328,7 @@ export async function adminDeductCredits(userId: string, payload: Record { return api.get('/admin/llm-billing/policies'); } diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx index 42f73d63..b893d92a 100644 --- a/video-gen-admin/src/pages/AdminCreditRecords.tsx +++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx @@ -47,10 +47,11 @@ const RECORD_TYPE_MAP: Record = { - charge: { text: '真实扣费', color: 'red' }, + charge: { text: '真实消费', color: 'red' }, refund: { text: '真实退款', color: 'blue' }, - hold: { text: '预扣占用', color: 'gold' }, - hold_release: { text: '预扣释放', color: 'green' }, + pre_deduct: { text: '历史固定预扣', color: 'default' }, + hold: { text: '历史预扣占用', color: 'gold' }, + hold_release: { text: '历史预扣释放', color: 'green' }, }; const userScopeOptions = [ @@ -105,10 +106,11 @@ const chargeKindOptions = [ const chargeActionOptions = [ { value: '', label: '全部交易动作' }, - { value: 'charge', label: '真实扣费' }, + { value: 'charge', label: '真实消费' }, { value: 'refund', label: '真实退款' }, - { value: 'hold', label: '预扣占用' }, - { value: 'hold_release', label: '预扣释放' }, + { value: 'pre_deduct', label: '历史固定预扣' }, + { value: 'hold', label: '历史预扣占用' }, + { value: 'hold_release', label: '历史预扣释放' }, ]; const sourceModuleOptions = [ @@ -306,7 +308,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: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceTypeLabel || a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join(';') }, { title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' }, ]; @@ -322,22 +324,18 @@ const AdminCreditRecords: React.FC = () => { ], summaryRows: [ ['总充值', exportSummary.totalRecharge], - ['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume], - [' · 真实扣费(独立统计:type=消费 & action=charge/NULL)', exportSummary.totalCharge], - [' · 预扣占用(独立统计:type=消费 & action=hold)', exportSummary.totalHold], - ['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund], - [' · 真实退款(独立统计:type=回退 & action=refund/NULL)', exportSummary.totalRefundReal], - [' · 预扣释放(独立统计:type=回退 & action=hold_release)', exportSummary.totalHoldRelease], - ['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume], + ['总真实消费', exportSummary.totalConsume], + ['总真实退款', exportSummary.totalRefund], + ['净消耗(总消费 − 总退款,≥ 0)', exportSummary.netConsume], ['交易笔数', exportSummary.transactionCount], ['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount], ['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount], - ['图片消费积分(仅真实扣费)', exportSummary.imageConsume], - ['视频消费积分(仅真实扣费)', exportSummary.videoConsume], - ['提词消费积分(仅真实扣费)', exportSummary.textConsume], - ['视频分析积分(仅真实扣费)', exportSummary.analysisConsume], + ['图片消费积分(仅真实消费)', exportSummary.imageConsume], + ['视频消费积分(仅真实消费)', exportSummary.videoConsume], + ['提词消费积分(仅真实消费)', exportSummary.textConsume], + ['视频分析积分(仅真实消费)', exportSummary.analysisConsume], ['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens], @@ -389,32 +387,18 @@ const AdminCreditRecords: React.FC = () => {
-
- 总消费 - (真实扣费 + 预扣占用) -
+
总消费
-{n(summary.totalConsume)}
-
- 真实 {n(summary.totalCharge)} - | - 预扣 {n(summary.totalHold)} -
+
仅统计真实消费流水
-
- 总回退 - (真实退款 + 预扣释放) -
+
总退款
+{n(summary.totalRefund)}
-
- 退款 {n(summary.totalRefundReal)} - | - 释放 {n(summary.totalHoldRelease)} -
+
仅统计真实退款流水
@@ -423,7 +407,7 @@ const AdminCreditRecords: React.FC = () => {
净消耗(实际用掉)
{n(summary.netConsume)}
-
总消费 − 总回退(≥ 0)
+
总消费 − 总退款(≥ 0)
@@ -498,8 +482,8 @@ const AdminCreditRecords: React.FC = () => { rowKey="id" dataSource={record.allocations || []} columns={[ - { title: '动作', dataIndex: 'allocationAction', width: 150 }, - { title: '积分来源', dataIndex: 'sourceType', width: 160, render: (v: string) => v || '-' }, + { title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' }, + { title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' }, { 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) : '-' }, diff --git a/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx index e715d8ef..c922c6c7 100644 --- a/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx +++ b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx @@ -6,7 +6,7 @@ import type { LlmBillingExecution } from '../types'; import { formatDate, formatDatePrecise } from '../utils/formatDate'; const STATUS_LABELS: Record = { - pre_deducted: { label: '已预扣', color: 'blue' }, + charged: { label: '已消费', color: 'blue' }, processing: { label: '处理中', color: 'processing' }, succeeded: { label: '业务成功', color: 'success' }, final_failed: { label: '最终失败', color: 'error' }, @@ -41,7 +41,7 @@ const AdminLlmBillingExecutions: React.FC = () => { }))} /> { { title: '用户', dataIndex: 'userId' }, { title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` }, { title: '模型', dataIndex: 'modelNameSnapshot' }, - { title: '预扣', dataIndex: 'preDeductCredits' }, + { title: '消费积分', dataIndex: 'chargeCredits' }, { title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` }, { title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return {config.label}; } }, { title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) }, diff --git a/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx index 3342cf84..30a041d6 100644 --- a/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx +++ b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx @@ -20,32 +20,64 @@ const AdminLlmBillingPolicies: React.FC = () => { 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); } }; + + 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 edit = (item?: LlmBillingPolicy) => { + setEditing(item || null); + setOpen(true); + form.resetFields(); + form.setFieldsValue(item + ? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive } + : { is_active: true, charge_credits: 5 }); + }; + const save = async () => { try { const values = await form.validateFields(); - if (editing) await updateLlmBillingPolicy(editing.id, values); else await createLlmBillingPolicy(values); - message.success('已保存'); setOpen(false); load(); - } catch (error: any) { if (!error?.errorFields) message.error(error?.message || '保存失败'); } + 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={}> + + return LLM积分配置} + extra={} + > {v ? '启用' : '停用'} }, { title: '操作', render: (_: unknown, r: LlmBillingPolicy) => }, ]} /> - setOpen(false)}> + setOpen(false)}>
- + + + +
; }; + export default AdminLlmBillingPolicies; diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx index 4479b6fb..1d38806d 100644 --- a/video-gen-admin/src/pages/AdminSettings.tsx +++ b/video-gen-admin/src/pages/AdminSettings.tsx @@ -63,31 +63,6 @@ const AdminSettings: React.FC = () => { try { const values = await form.validateFields(); - // 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分 - const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null - ? !['0', 'false', 'no', 'off', 'disabled'].includes( - String(values.llm_billing_enabled).trim().toLowerCase(), - ) - : false; - if (llmBillingEnabled) { - const holdKeys = [ - 'optimize_hold_credits', - 'llm_hold_credits_default', - 'llm_hold_credits_generation_record_prompt', - 'llm_hold_credits_module_image_prompt', - 'llm_hold_credits_module_video_prompt', - 'llm_hold_credits_shot_video_analysis', - ]; - const invalidKey = holdKeys.find((key) => { - const numericValue = Number(values[key]); - return !Number.isFinite(numericValue) || numericValue <= 0; - }); - if (invalidKey) { - message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0'); - return; - } - } - setSaving(true); for (const config of configs) { const newVal = values[config.key]; diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx index 7b90ca40..f5f15977 100644 --- a/video-gen-admin/src/pages/AdminUsers.tsx +++ b/video-gen-admin/src/pages/AdminUsers.tsx @@ -816,9 +816,9 @@ const AdminUsers: React.FC = () => { pagination={false} dataSource={creditBalances} columns={[ - { title: '来源', dataIndex: 'sourceType', width: 150, render: (v: string) => v || '-' }, + { title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' }, { title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' }, - { title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' }, + { title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' }, { title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, @@ -826,7 +826,7 @@ const AdminUsers: React.FC = () => { { 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} }, + { title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => {r.statusLabel || v || '-'} }, ]} scroll={{ x: 1410, y: 460 }} /> diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index 5da16942..8f3e8fa5 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -868,17 +868,17 @@ export interface VideoPromptSchemaPreviewOut { export interface AdminCreditRecordSummary { totalRecharge: number; - /** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */ + /** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */ totalConsume: number; - /** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */ + /** 总退款:仅真实退款,不含历史 hold_release */ totalRefund: number; - /** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */ + /** 兼容字段:与 totalConsume 同真实消费口径 */ totalCharge: number; - /** 独立统计列:预扣占用 hold */ + /** 历史兼容字段:hold 占用,不计入 totalConsume */ totalHold: number; - /** 独立统计列:真实退款 refund(含历史 NULL) */ + /** 兼容字段:与 totalRefund 同真实退款口径 */ totalRefundReal: number; - /** 独立统计列:预扣释放 hold_release(type=refund, action=hold_release) */ + /** 历史兼容字段:hold_release,不计入 totalRefund */ totalHoldRelease: number; /** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */ netConsume: number; @@ -887,13 +887,13 @@ export interface AdminCreditRecordSummary { generationAttemptCount: number; imageGenerationCount: number; videoGenerationCount: number; - /** 子分类消费(图片)仅真实扣费 charge 口径 */ + /** 子分类消费(图片)仅真实消费 charge 口径 */ imageConsume: number; - /** 子分类消费(视频)仅真实扣费 charge 口径 */ + /** 子分类消费(视频)仅真实消费 charge 口径 */ videoConsume: number; - /** 子分类消费(提词)仅真实扣费 charge 口径 */ + /** 子分类消费(提词)仅真实消费 charge 口径 */ textConsume: number; - /** 子分类消费(分析)仅真实扣费 charge 口径 */ + /** 子分类消费(分析)仅真实消费 charge 口径 */ analysisConsume: number; totalTokens: number; inputTokens: number; @@ -905,9 +905,12 @@ export interface AdminCreditRecordAllocation { creditBalanceId: string; sourceAllocationId?: string | null; allocationAction: string; + allocationActionLabel?: string; amount: number; creditLevel?: string; + creditLevelLabel?: string; sourceType?: string; + sourceTypeLabel?: string; sourceId?: string; validFrom?: string; expiresAt?: string; @@ -1503,7 +1506,7 @@ export interface LlmBillingPolicy { id: string; sceneCode: string; sceneName: string; - preDeductCredits: number; + chargeCredits: number; isActive: boolean; version: number; createdBy?: string | null; @@ -1545,7 +1548,7 @@ export interface LlmBillingExecution { modelNameSnapshot?: string | null; providerSnapshot?: string | null; requestTime: string; - preDeductCredits: number; + chargeCredits: number; status: string; totalCallCount: number; successfulCallCount: number; diff --git a/video-gen-api/app/api/admin/credit_management.py b/video-gen-api/app/api/admin/credit_management.py index 2a980abc..baee5812 100644 --- a/video-gen-api/app/api/admin/credit_management.py +++ b/video-gen-api/app/api/admin/credit_management.py @@ -8,7 +8,12 @@ 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.enums.credit_balance import ( + CREDIT_BALANCE_SOURCE_TYPE_LABELS, + CREDIT_BALANCE_STATUS_LABELS, + CREDIT_LEVEL_LABELS, + CreditBalanceSourceType, +) from app.models.credit.balance import UserCreditBalance from app.models.credit.product import CreditProduct from app.models.user import User @@ -248,7 +253,9 @@ async def list_user_credit_balances( { "id": item.id, "credit_level": item.credit_level, + "credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level), "source_type": item.source_type, + "source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type), "source_id": item.source_id, "grant_amount": float(item.grant_amount), "unspent_amount": float(item.unspent_amount), @@ -258,7 +265,8 @@ async def list_user_credit_balances( "valid_from": item.valid_from, "expires_at": item.expires_at, "last_usable_at": last_usable_at(item.expires_at), - "status": effective_balance_status(item, request_time=checked_at), + "status": (status_value := effective_balance_status(item, request_time=checked_at)), + "status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value), } for item in result.scalars().all() ] diff --git a/video-gen-api/app/api/admin/llm_billing.py b/video-gen-api/app/api/admin/llm_billing.py index 2d8c81a1..369f45fd 100644 --- a/video-gen-api/app/api/admin/llm_billing.py +++ b/video-gen-api/app/api/admin/llm_billing.py @@ -16,13 +16,29 @@ from app.utils.id_gen import generate_id router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"]) +def _policy_out(policy: LlmBillingPolicyModel) -> dict: + # 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。 + return { + "id": policy.id, + "scene_code": policy.scene_code, + "scene_name": policy.scene_name, + "charge_credits": float(policy.pre_deduct_credits), + "is_active": policy.is_active, + "version": policy.version, + "created_by": policy.created_by, + "updated_by": policy.updated_by, + "created_at": policy.created_at, + "updated_at": policy.updated_at, + } + + @router.get("/policies") async def list_policies( admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code)) - return list(result.scalars().all()) + return [_policy_out(item) for item in result.scalars().all()] @router.post("/policies") @@ -33,14 +49,20 @@ async def create_policy( ): 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)) + 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, + # 保留历史物理字段,不做无业务价值的数据库重命名迁移。 + pre_deduct_credits=data.charge_credits, is_active=data.is_active, version=1, created_by=admin.id, @@ -48,8 +70,17 @@ async def create_policy( ) 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 + policy_id = policy.id + scene_code = policy.scene_code + charge_credits = float(policy.pre_deduct_credits) + log_operation_event( + domain="llm_billing", + module="admin", + event_type="LLM_BILLING_POLICY_CREATED", + user_id=admin.id, + detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id}, + ) + return _policy_out(policy) @router.put("/policies/{policy_id}") @@ -59,20 +90,41 @@ async def update_policy( 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()) + 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计费场景不存在") + 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) + if "charge_credits" in payload: + policy.pre_deduct_credits = payload.pop("charge_credits") + if "is_active" in payload: + policy.is_active = payload["is_active"] policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name) policy.version += 1 policy.updated_by = admin.id await db.flush() - log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_UPDATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "version": policy.version}) - return policy + + log_operation_event( + domain="llm_billing", + module="admin", + event_type="LLM_BILLING_POLICY_UPDATED", + user_id=admin.id, + detail={ + "scene_code": policy.scene_code, + "policy_id": policy.id, + "version": policy.version, + "charge_credits": float(policy.pre_deduct_credits), + "is_active": policy.is_active, + }, + ) + return _policy_out(policy) @router.get("/executions") @@ -85,5 +137,12 @@ async def list_executions( 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) + 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/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index bb41bbbd..e2eb5c52 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -532,7 +532,7 @@ async def create_shot_task_set( task_set, created_new = await create_task_set(db, current_user=current_user, req=req) task_set_id = str(task_set.id) if not created_new: - # 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。 + # 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。 await db.rollback() return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1)) diff --git a/video-gen-api/app/enums/credit_balance.py b/video-gen-api/app/enums/credit_balance.py index 246ce476..457ec603 100644 --- a/video-gen-api/app/enums/credit_balance.py +++ b/video-gen-api/app/enums/credit_balance.py @@ -13,6 +13,11 @@ CREDIT_LEVEL_SORT = { CreditLevel.GENERAL.value: 20, } +CREDIT_LEVEL_LABELS = { + CreditLevel.PROMOTIONAL.value: "活动积分", + CreditLevel.GENERAL.value: "普通积分", +} + class CreditBalanceSourceType(StrEnum): REGISTER_GIFT = "register_gift" @@ -26,6 +31,19 @@ class CreditBalanceSourceType(StrEnum): BUSINESS_REFUND = "business_refund" +CREDIT_BALANCE_SOURCE_TYPE_LABELS = { + CreditBalanceSourceType.REGISTER_GIFT.value: "注册赠送", + CreditBalanceSourceType.DAILY_LOGIN.value: "每日登录赠送", + CreditBalanceSourceType.SIGN_IN.value: "签到赠送", + CreditBalanceSourceType.ACTIVITY.value: "活动赠送", + CreditBalanceSourceType.ADMIN_GRANT.value: "管理员赠送", + CreditBalanceSourceType.SUBSCRIPTION_GRANT.value: "订阅积分发放", + CreditBalanceSourceType.CREDIT_ADDON.value: "积分增值包", + CreditBalanceSourceType.LEGACY_MIGRATION.value: "历史积分迁移", + CreditBalanceSourceType.BUSINESS_REFUND.value: "业务退款", +} + + class CreditBalanceStatus(StrEnum): SCHEDULED = "scheduled" ACTIVE = "active" @@ -35,6 +53,16 @@ class CreditBalanceStatus(StrEnum): CANCELLED = "cancelled" +CREDIT_BALANCE_STATUS_LABELS = { + CreditBalanceStatus.SCHEDULED.value: "待生效", + CreditBalanceStatus.ACTIVE.value: "有效", + CreditBalanceStatus.CONSUMED.value: "已用完", + CreditBalanceStatus.EXPIRED.value: "已过期", + CreditBalanceStatus.REVOKED.value: "已撤销", + CreditBalanceStatus.CANCELLED.value: "已取消", +} + + class CreditAllocationAction(StrEnum): GRANT = "grant" CONSUME = "consume" @@ -44,3 +72,15 @@ class CreditAllocationAction(StrEnum): REVOKE = "revoke" UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out" UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in" + + +CREDIT_ALLOCATION_ACTION_LABELS = { + CreditAllocationAction.GRANT.value: "发放", + CreditAllocationAction.CONSUME.value: "消费", + CreditAllocationAction.REFUND_AVAILABLE.value: "有效积分退款", + CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款", + CreditAllocationAction.EXPIRE.value: "积分过期", + CreditAllocationAction.REVOKE.value: "积分撤销", + CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出", + CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入", +} diff --git a/video-gen-api/app/enums/credit_record.py b/video-gen-api/app/enums/credit_record.py index bd74076e..6e607d8c 100644 --- a/video-gen-api/app/enums/credit_record.py +++ b/video-gen-api/app/enums/credit_record.py @@ -126,11 +126,11 @@ class CreditRecordBillingScene(str, Enum): CREDIT_RECORD_ACTION_LABELS = { - CreditRecordAction.CHARGE.value: "真实扣费", - CreditRecordAction.PRE_DEDUCT.value: "固定预扣", + CreditRecordAction.CHARGE.value: "真实消费", + CreditRecordAction.PRE_DEDUCT.value: "历史固定预扣", CreditRecordAction.REFUND.value: "真实退款", - CreditRecordAction.HOLD.value: "预扣占用", - CreditRecordAction.HOLD_RELEASE.value: "预扣释放", + CreditRecordAction.HOLD.value: "历史预扣占用", + CreditRecordAction.HOLD_RELEASE.value: "历史预扣释放", } CREDIT_RECORD_TYPE_LABELS = { diff --git a/video-gen-api/app/enums/llm_billing.py b/video-gen-api/app/enums/llm_billing.py index a4b06ad9..4449ae5a 100644 --- a/video-gen-api/app/enums/llm_billing.py +++ b/video-gen-api/app/enums/llm_billing.py @@ -12,12 +12,12 @@ class LlmBillingLedgerState(StrEnum): INVALID = "invalid" BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费 RELEASED = "refunded" # 历史兼容别名 - CHARGED = "succeeded" # 历史兼容别名 - + CHARGED = "active" # 当前 attempt 已完成真实消费,可继续执行 class LlmBillingExecutionStatus(StrEnum): - PRE_DEDUCTED = "pre_deducted" + CHARGED = "charged" + PRE_DEDUCTED = "pre_deducted" # 历史兼容;新数据不得再创建 PROCESSING = "processing" SUCCEEDED = "succeeded" FINAL_FAILED = "final_failed" @@ -34,10 +34,11 @@ class LlmCallAttemptStatus(StrEnum): class LlmBillingEvent(StrEnum): - 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" + CHARGE_START = "LLM_CHARGE_START" + CHARGE_IDEMPOTENT_HIT = "LLM_CHARGE_IDEMPOTENT_HIT" + CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS" + CHARGE_INSUFFICIENT = "LLM_CHARGE_INSUFFICIENT" + CHARGE_CONFIG_INVALID = "LLM_CHARGE_CONFIG_INVALID" EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START" EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS" EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED" diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py index 02e701ba..90036d15 100644 --- a/video-gen-api/app/main.py +++ b/video-gen-api/app/main.py @@ -405,7 +405,7 @@ async def _seed_data(): ("/generation-ai", "创作记录", "BulbOutlined", 3, None), ("/generation-records", "项目记录", "VideoCameraOutlined", 3, None), ("/credit-products", "积分产品", "GiftOutlined", 4, None), - ("/llm-billing-policies", "LLM预扣配置", "RobotOutlined", 5, "模型设置"), + ("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"), ("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"), ("/notifications", "消息推送", "BellOutlined", 5, None), ("/payment-stats", "支付统计", "LineChartOutlined", 6, None), diff --git a/video-gen-api/app/schemas/llm_billing.py b/video-gen-api/app/schemas/llm_billing.py index e97c3364..13671e7b 100644 --- a/video-gen-api/app/schemas/llm_billing.py +++ b/video-gen-api/app/schemas/llm_billing.py @@ -8,13 +8,13 @@ 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) + charge_credits: float = Field(..., gt=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) + charge_credits: float | None = Field(default=None, gt=0, le=999999999.99, multiple_of=0.01) is_active: bool | None = None @@ -22,7 +22,7 @@ class LlmBillingPolicyOut(BaseModel): id: str scene_code: str scene_name: str - pre_deduct_credits: float + charge_credits: float is_active: bool version: int created_by: str | None = None @@ -30,8 +30,6 @@ class LlmBillingPolicyOut(BaseModel): created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} - class LlmCallAttemptOut(BaseModel): id: str @@ -70,7 +68,7 @@ class LlmBillingExecutionOut(BaseModel): model_name_snapshot: str | None = None provider_snapshot: str | None = None request_time: datetime - pre_deduct_credits: float + charge_credits: float status: str total_call_count: int successful_call_count: int @@ -84,5 +82,3 @@ class LlmBillingExecutionOut(BaseModel): 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/services/admin_credit_record_service.py b/video-gen-api/app/services/admin_credit_record_service.py index 6b4bb7c0..a9b98dff 100644 --- a/video-gen-api/app/services/admin_credit_record_service.py +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -6,6 +6,11 @@ from typing import Any from sqlalchemy import and_, case, distinct, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.enums.credit_balance import ( + CREDIT_ALLOCATION_ACTION_LABELS, + CREDIT_BALANCE_SOURCE_TYPE_LABELS, + CREDIT_LEVEL_LABELS, +) from app.enums.credit_record import ( CREDIT_RECORD_ACTION_LABELS, CREDIT_RECORD_BILLING_SCENE_LABELS, @@ -129,7 +134,23 @@ def _build_filters( if charge_kind: filters.append(CreditRecord.charge_kind == charge_kind) if charge_action: - filters.append(CreditRecord.charge_action == charge_action) + if charge_action == "charge": + # 历史 LLM Billing 已经真实减余额但元数据曾写成 pre_deduct,查询时统一按真实消费兼容。 + filters.append(or_( + CreditRecord.charge_action == "charge", + and_( + CreditRecord.charge_action == "pre_deduct", + CreditRecord.llm_billing_execution_id.is_not(None), + ), + )) + elif charge_action == "pre_deduct": + # 只展示真正的历史固定预扣,不把历史 LLM 真实消费混入。 + filters.append(and_( + CreditRecord.charge_action == "pre_deduct", + CreditRecord.llm_billing_execution_id.is_(None), + )) + else: + filters.append(CreditRecord.charge_action == charge_action) if source_module: filters.append(CreditRecord.source_module == source_module) if source_step_code: @@ -172,6 +193,25 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di return deleted_map +def _is_legacy_llm_charge(record: CreditRecord) -> bool: + return bool( + record.llm_billing_execution_id + and record.charge_action == "pre_deduct" + and record.type == "consume" + ) + + +def _normalized_charge_action(record: CreditRecord) -> str | None: + return "charge" if _is_legacy_llm_charge(record) else record.charge_action + + +def _normalized_description(record: CreditRecord) -> str | None: + description = record.description + if _is_legacy_llm_charge(record) and description: + return description.replace("固定预扣积分", "积分消费").replace("固定预扣", "积分消费") + return description + + def _record_to_item( record: CreditRecord, user: User | None, @@ -185,6 +225,7 @@ def _record_to_item( user_type = record.user_type_snapshot or (user.user_type if user else None) frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None) + charge_action = _normalized_charge_action(record) return { "id": record.id, "user_id": record.user_id, @@ -204,7 +245,7 @@ def _record_to_item( "balance_delta": _round2(record.balance_delta), "expired_amount": _round2(record.expired_amount), "balance_after": _round2(record.balance_after), - "description": record.description, + "description": _normalized_description(record), "related_id": record.related_id, "biz_key": record.biz_key, "refund_for_biz_key": record.refund_for_biz_key, @@ -215,8 +256,8 @@ def _record_to_item( "attempt_no": record.attempt_no, "charge_kind": record.charge_kind, "charge_kind_label": _label(CREDIT_RECORD_CHARGE_KIND_LABELS, record.charge_kind), - "charge_action": record.charge_action, - "charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, record.charge_action), + "charge_action": charge_action, + "charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, charge_action), "credit_subject": record.credit_subject, "credit_subject_label": _label(CREDIT_RECORD_SUBJECT_LABELS, record.credit_subject), "media_type": record.media_type, @@ -321,9 +362,12 @@ async def list_admin_credit_records( "credit_balance_id": allocation.credit_balance_id, "source_allocation_id": allocation.source_allocation_id, "allocation_action": allocation.allocation_action, + "allocation_action_label": _label(CREDIT_ALLOCATION_ACTION_LABELS, allocation.allocation_action), "amount": _round2(allocation.amount), "credit_level": allocation.credit_level_snapshot, + "credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot), "source_type": allocation.source_type_snapshot, + "source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot), "source_id": allocation.source_id_snapshot, "valid_from": _iso(allocation.valid_from_snapshot), "expires_at": _iso(allocation.expires_at_snapshot), @@ -334,61 +378,39 @@ async def list_admin_credit_records( }) items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows] - # 说明: - # - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值 - # team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费 - # - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正 - # 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release') - # - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs - # - # 口径更新(Bug 修复 · 第二次修正): - # 1. 消费类统计仅看 type=consume(排除 team_internal 团队内部转账) - # 2. 真实扣费 / 预扣占用 / 真实退款 / 预扣释放 全部改为"独立统计列",不再用差值推导 - # (避免任何一类范围不同导致推导失真) - # - # 消费类(type=consume): - # - total_charge :真实扣费 charge_action in (NULL, 'charge') abs 求和 - # - total_hold :预扣占用 charge_action = 'hold' abs 求和 - # - total_consume :total_charge + total_hold = charge_action in (NULL, charge, hold) abs 求和 - # 回退类(type=refund): - # - total_refund_real :真实退款 charge_action in (NULL, 'refund') abs 求和 - # - total_hold_release :预扣释放 charge_action = 'hold_release' abs 求和 - # - total_refund :total_refund_real + total_hold_release = type=refund 全部 abs 求和 - # 净消耗 net_consume = max(total_consume - total_refund, 0) - # - # 按积分 subject 分类的子项(图片/视频/提词/分析)仍保持「仅真实扣费 charge」口径不变: - # 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。 + # 当前主口径只统计真实消费/真实退款;hold/hold_release 仅保留历史兼容字段,不再并入总消费/总退款。 + # 历史 LLM Billing 的 consume + pre_deduct + llm_billing_execution_id 本质已经真实减余额, + # 查询统计时按 charge 兼容,但不批量改写历史数据库。 + _legacy_llm_charge_action = and_( + CreditRecord.charge_action == "pre_deduct", + CreditRecord.llm_billing_execution_id.is_not(None), + ) _real_charge_action = or_( CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge", - ) - _charge_or_hold_action = or_( - CreditRecord.charge_action.is_(None), - CreditRecord.charge_action == "charge", - CreditRecord.charge_action == "hold", + _legacy_llm_charge_action, ) _real_refund_action = or_( CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "refund", ) - # 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账) _consume_type = CreditRecord.type == "consume" - # 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund) + _refund_type = CreditRecord.type == "refund" _hold_release_filter = and_( - CreditRecord.type == "refund", + _refund_type, CreditRecord.charge_action == "hold_release", ) - _refund_type = CreditRecord.type == "refund" + summary_query = select( # 0: 充值 func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), - # 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用) - func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0), - # 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水) - func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0), + # 1: 总真实消费 + func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), + # 2: 总真实退款 + func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0), # 3: 交易笔数 func.count(CreditRecord.id), - # 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径) + # 4-11: 生成统计与按 subject 的真实消费 func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)), func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), @@ -397,17 +419,17 @@ async def list_admin_credit_records( func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), - # 12-14: Token - func.coalesce(func.sum(CreditRecord.total_tokens), 0), - func.coalesce(func.sum(CreditRecord.input_tokens), 0), - func.coalesce(func.sum(CreditRecord.output_tokens), 0), - # 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge')) + # 12-14: Token 仅统计真实 LLM/媒体消费流水,避免退款/历史释放重复累计。 + func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.total_tokens), else_=0)), 0), + func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.input_tokens), else_=0)), 0), + func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.output_tokens), else_=0)), 0), + # 15: total_charge 与 total_consume 同口径,保留字段供旧前端兼容。 func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), - # 16: 预扣占用 total_hold(独立列:type=consume AND charge_action='hold') + # 16: 历史 hold 独立统计,不计入总消费。 func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0), - # 17: 真实退款 total_refund_real(独立列:type=refund AND charge_action in (NULL, 'refund')) + # 17: 真实退款,与 total_refund 同口径。 func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0), - # 18: 预扣释放 total_hold_release(独立列:type=refund AND charge_action='hold_release') + # 18: 历史 hold_release 独立统计,不计入总退款。 func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0), ).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True) if where_clause is not None: diff --git a/video-gen-api/app/services/credits.py b/video-gen-api/app/services/credits.py index d47ded76..7aee4758 100644 --- a/video-gen-api/app/services/credits.py +++ b/video-gen-api/app/services/credits.py @@ -10,11 +10,6 @@ from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio -async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float: - """历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。""" - return 0.0 - - async def _get_credit_ratio( db: AsyncSession, *, diff --git a/video-gen-api/app/services/generation/billing_service.py b/video-gen-api/app/services/generation/billing_service.py index 3b5262d8..2499b7cb 100644 --- a/video-gen-api/app/services/generation/billing_service.py +++ b/video-gen-api/app/services/generation/billing_service.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from dataclasses import asdict, dataclass -from typing import Any, Mapping +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,34 +11,20 @@ from app.enums.credit_record import CreditRecordChargeKind, CreditRecordOwnerTyp from app.models.credit_record import CreditRecord from app.models.generation_record import GenerationRecord from app.services.generation.media_reference_service import calculate_media_reference_usage -from app.models.module_generation_step import ModuleGenerationStep -from app.models.token_usage import TokenUsage -from app.models.system_config import SystemConfig from app.services.credit_record_meta_service import ( CreditRecordMeta, build_generation_media_meta, - build_generation_record_prompt_meta, - build_module_step_prompt_meta, - build_shot_video_analysis_meta, ) -from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits_result -from app.utils.id_gen import generate_id +from app.services.credits import calc_image_credits, calc_video_credits, deduct_credits_result -CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value -CHARGE_FILE_PARSE = CreditRecordChargeKind.FILE_PARSE.value -CHARGE_VISION_INPUT = CreditRecordChargeKind.VISION_INPUT.value CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value -CHARGE_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value -OWNER_MODULE_GENERATION_STEP = CreditRecordOwnerType.MODULE_GENERATION_STEP.value -OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value -OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value _BIZ_KEY_PATTERN = re.compile( - r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|pre_deduct|hold|hold_release)$" + r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|llm_charge|refund|pre_deduct|hold|hold_release)$" ) @@ -76,15 +62,6 @@ def _round2(value: float | int | None) -> float: return round(float(value or 0), 2) -def _safe_int(value: Any, default: int = 0) -> int: - try: - if value is None: - return default - return int(value) - except Exception: - return default - - def build_credit_biz_key( *, owner_type: str, @@ -101,8 +78,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", "pre_deduct", "hold", "hold_release"): - raise ValueError("action 仅支持 charge/refund/pre_deduct/hold/hold_release") + if action not in ("charge", "llm_charge", "refund", "pre_deduct", "hold", "hold_release"): + raise ValueError("action 仅支持 charge/llm_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}" @@ -119,27 +96,6 @@ def parse_credit_biz_key(biz_key: str | None) -> dict[str, Any] | None: return data -async def _get_config_float_or_none(db: AsyncSession, key: str) -> float | None: - result = await db.execute(select(SystemConfig).where(SystemConfig.key == key).limit(1)) - config = result.scalar_one_or_none() - if not config: - return None - try: - return float(config.value) - except Exception: - return None - - -async def _calc_optional_token_credits(db: AsyncSession, tokens: int, config_key: str) -> float: - tokens = _safe_int(tokens) - if tokens <= 0: - return 0.0 - rate = await _get_config_float_or_none(db, config_key) - if rate is None: - return 0.0 - return round(tokens * rate / 1000, 2) - - async def get_next_credit_attempt_no( db: AsyncSession, *, @@ -215,271 +171,6 @@ async def deduct_credits_locked_once( ) -async def charge_chatapi_prompt_usage( - db: AsyncSession, - *, - record: GenerationRecord, - usage: Mapping[str, Any], - project_name: str | None = None, -) -> BillingSummary: - """项目记录提示词整理扣费;不参与生成失败媒体退款。""" - project_name = project_name or "AI生成任务" - items: list[BillingItem] = [] - - attempt_no = 1 - owner_type = OWNER_GENERATION_RECORD - owner_id = record.id - - input_tokens = _safe_int(usage.get("input_tokens")) - output_tokens = _safe_int(usage.get("output_tokens")) - text_credits = await calc_text_credits(db, input_tokens, output_tokens) - text_meta = await build_generation_record_prompt_meta( - db, - record_id=record.id, - attempt_no=attempt_no, - charge_kind=CHARGE_TEXT_PROMPT, - usage=usage, - ) - items.append( - await deduct_credits_locked_once( - db, - user_id=record.user_id, - amount=text_credits, - description=f"提示词优化 - {project_name}", - related_id=record.id, - charge_key=CHARGE_TEXT_PROMPT, - biz_key=build_credit_biz_key( - owner_type=owner_type, - owner_id=owner_id, - attempt_no=attempt_no, - charge_kind=CHARGE_TEXT_PROMPT, - action="charge", - ), - attempt_no=attempt_no, - record_meta=text_meta, - ) - ) - - file_tokens = usage.get("file_parse_tokens") or usage.get("file_tokens") or usage.get("document_tokens") or 0 - file_parse_credits = await _calc_optional_token_credits(db, _safe_int(file_tokens), "file_parse_credits_per_1000_tokens") - file_meta = await build_generation_record_prompt_meta( - db, - record_id=record.id, - attempt_no=attempt_no, - charge_kind=CHARGE_FILE_PARSE, - usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0}, - ) - items.append( - await deduct_credits_locked_once( - db, - user_id=record.user_id, - amount=file_parse_credits, - description="文件解析Token", - related_id=record.id, - charge_key=CHARGE_FILE_PARSE, - biz_key=build_credit_biz_key( - owner_type=owner_type, - owner_id=owner_id, - attempt_no=attempt_no, - charge_kind=CHARGE_FILE_PARSE, - action="charge", - ), - attempt_no=attempt_no, - record_meta=file_meta, - ) - ) - - vision_tokens = usage.get("vision_input_tokens") or usage.get("image_input_tokens") or usage.get("image_tokens") or 0 - vision_input_credits = await _calc_optional_token_credits(db, _safe_int(vision_tokens), "vision_input_credits_per_1000_tokens") - vision_meta = await build_generation_record_prompt_meta( - db, - record_id=record.id, - attempt_no=attempt_no, - charge_kind=CHARGE_VISION_INPUT, - usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0}, - ) - items.append( - await deduct_credits_locked_once( - db, - user_id=record.user_id, - amount=vision_input_credits, - description="图片理解Token", - related_id=record.id, - charge_key=CHARGE_VISION_INPUT, - biz_key=build_credit_biz_key( - owner_type=owner_type, - owner_id=owner_id, - attempt_no=attempt_no, - charge_kind=CHARGE_VISION_INPUT, - action="charge", - ), - attempt_no=attempt_no, - record_meta=vision_meta, - ) - ) - - if hasattr(record, "text_credits_cost"): - record.text_credits_cost = round(text_credits + file_parse_credits + vision_input_credits, 2) - if hasattr(record, "text_tokens_used"): - record.text_tokens_used = _safe_int(usage.get("total_tokens"), input_tokens + output_tokens) - - return BillingSummary(record_id=record.id, user_id=record.user_id, items=items) - - -async def charge_module_prompt_usage( - db: AsyncSession, - *, - user_id: str, - step_id: str, - usage: Mapping[str, Any], - description: str, - attempt_no: int = 1, -) -> BillingSummary: - """爆款开头复刻/拆镜复刻模块图片/视频 AI 提词扣文本积分。 - - 文本提词属于已经发生的 LLM 消费: - - 调用成功后按 input_tokens + output_tokens 扣费。 - - 不参与后续图片/视频媒体生成失败退款。 - - 通过 module_generation_step:{step_id}:attempt:1:text_prompt:charge 幂等。 - """ - input_tokens = _safe_int(usage.get("input_tokens")) - output_tokens = _safe_int(usage.get("output_tokens")) - text_credits = await calc_text_credits(db, input_tokens, output_tokens) - biz_key = build_credit_biz_key( - owner_type=OWNER_MODULE_GENERATION_STEP, - owner_id=step_id, - attempt_no=attempt_no, - charge_kind=CHARGE_TEXT_PROMPT, - action="charge", - ) - record_meta = await build_module_step_prompt_meta(db, step_id=step_id, attempt_no=attempt_no, usage=usage) - item = await deduct_credits_locked_once( - db, - user_id=user_id, - amount=text_credits, - description=description, - related_id=step_id, - charge_key=CHARGE_TEXT_PROMPT, - biz_key=biz_key, - attempt_no=attempt_no, - record_meta=record_meta, - ) - - result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1)) - step = result.scalar_one_or_none() - if step: - step.token_usage_id = record_meta.token_usage_id - step.model_config_id = usage.get("model_config_id") - step.input_tokens = record_meta.input_tokens - step.output_tokens = record_meta.output_tokens - step.total_tokens = record_meta.total_tokens - step.text_credits_cost = text_credits - - return BillingSummary(record_id=step_id, user_id=user_id, items=[item]) - - -async def charge_shot_video_analysis_usage( - db: AsyncSession, - *, - user_id: str, - owner_type: str, - owner_id: str, - usage: Mapping[str, Any], - description: str, - billing_scene: str, - source_project_id: str | None = None, - source_step_id: str | None = None, - attempt_no: int | None = None, -) -> BillingSummary: - """拆镜复刻视频分析扣分析积分。 - - 视频分析属于“文字提示词 + 视频素材”的模型调用类消费, - 按 input_tokens + output_tokens 参考文本积分规则计费, - 但账务归类为 analysis/video_analysis,避免混入提词优化统计。 - """ - attempt_no = attempt_no or await get_next_credit_attempt_no( - db, - owner_type=owner_type, - owner_id=owner_id, - charge_kind=CHARGE_VIDEO_ANALYSIS, - ) - input_tokens = _safe_int(usage.get("input_tokens")) - output_tokens = _safe_int(usage.get("output_tokens")) - amount = await calc_text_credits(db, input_tokens, output_tokens) - biz_key = build_credit_biz_key( - owner_type=owner_type, - owner_id=owner_id, - attempt_no=attempt_no, - charge_kind=CHARGE_VIDEO_ANALYSIS, - action="charge", - ) - usage_snapshot = dict(usage) - token_usage_result = await db.execute( - select(TokenUsage) - .where( - TokenUsage.owner_type == owner_type, - TokenUsage.owner_id == owner_id, - TokenUsage.biz_key == biz_key, - ) - .order_by(TokenUsage.created_at.asc()) - .limit(1) - ) - token_usage = token_usage_result.scalar_one_or_none() - if token_usage is None: - token_usage = TokenUsage( - id=generate_id(), - model_config_id=usage_snapshot.get("model_config_id"), - user_id=user_id, - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=_safe_int( - usage_snapshot.get("total_tokens"), - input_tokens + output_tokens, - ), - owner_type=owner_type, - owner_id=owner_id, - biz_key=biz_key, - source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, - source_step_code="video_analysis", - ) - db.add(token_usage) - await db.flush() - usage_snapshot["token_usage_id"] = token_usage.id - record_meta = await build_shot_video_analysis_meta( - db, - owner_type=owner_type, - owner_id=owner_id, - attempt_no=attempt_no, - usage=usage_snapshot, - billing_scene=billing_scene, - source_project_id=source_project_id, - source_step_id=source_step_id, - ) - item = await deduct_credits_locked_once( - db, - user_id=user_id, - amount=amount, - description=description, - related_id=owner_id, - charge_key=CHARGE_VIDEO_ANALYSIS, - biz_key=biz_key, - attempt_no=attempt_no, - record_meta=record_meta, - ) - - if record_meta.token_usage_id: - result = await db.execute(select(TokenUsage).where(TokenUsage.id == record_meta.token_usage_id).limit(1)) - token_usage = result.scalar_one_or_none() - if token_usage: - token_usage.owner_type = token_usage.owner_type or owner_type - token_usage.owner_id = token_usage.owner_id or owner_id - token_usage.biz_key = token_usage.biz_key or biz_key - token_usage.source_module = token_usage.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value - token_usage.source_step_code = token_usage.source_step_code or "video_analysis" - - return BillingSummary(record_id=owner_id, user_id=user_id, items=[item]) - - async def charge_generation_media_by_params( db: AsyncSession, *, 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 18b11de6..bb0077ad 100644 --- a/video-gen-api/app/services/generation/prompt_optimize_service.py +++ b/video-gen-api/app/services/generation/prompt_optimize_service.py @@ -55,7 +55,7 @@ from app.services.llm_billing import ( log_provider_success, finalize_llm_business_failure, mark_business_success, - pre_deduct, + charge_llm_credits, ) 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 @@ -466,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 pre_deduct(db, ctx) + await charge_llm_credits(db, ctx) await db.commit() except Exception: await db.rollback() @@ -638,7 +638,7 @@ async def optimize_generation_prompt( ) # 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后, # 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按 - # 原积分来源退回固定预扣,不能让账务永久停留在 processing。 + # 原积分来源退回场景积分消费,不能让账务永久停留在 processing。 business_already_succeeded = False try: await db.rollback() @@ -672,7 +672,7 @@ async def optimize_generation_prompt( 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="提词已生成但本地暂存失败,固定预扣积分已按原来源退回") + raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,场景消费积分已按原来源退回") _log_event( GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED, diff --git a/video-gen-api/app/services/generation/prompt_service.py b/video-gen-api/app/services/generation/prompt_service.py deleted file mode 100644 index 666f5d40..00000000 --- a/video-gen-api/app/services/generation/prompt_service.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import json -import time -from types import SimpleNamespace -from typing import Any - -import httpx -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.config import settings -from app.models.chat_generation_task import ChatGenerationTask -from app.models.model_config import ModelConfig -from app.models.token_usage import TokenUsage -from app.services.generation.log_service import log_provider_call -from app.services.provider_limit import provider_limit -from app.utils.id_gen import generate_id - - -def _absolute_url(url: str) -> str: - if url.startswith("http://") or url.startswith("https://") or url.startswith("data:"): - return url - base = settings.BASE_URL.rstrip("/") - return f"{base}/{url.lstrip('/')}" - - -def _load_refs(record: ChatGenerationTask) -> list[dict]: - if not record.media_references: - return [] - try: - data = json.loads(record.media_references) - return data if isinstance(data, list) else [] - except Exception: - return [] - - -async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]: - from app.utils.media import get_llm_media_as_base64, media_to_base64 - - if record.gen_type == "image": - params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}" - else: - params = f"视频参数:时长={record.duration or 4}秒,比例={record.aspect_ratio or '16:9'},分辨率={record.resolution or '480p'}" - - text = ( - f"生成类型:{record.gen_type}\n" - f"{params}\n" - f"用户描述:{record.original_prompt}\n\n" - "请只输出最终可直接用于图片/视频生成模型的 prompt,不要说你已经生成了图片或视频。" - ) - parts: list[dict[str, Any]] = [{"type": "text", "text": text}] - for ref in _load_refs(record): - ref_type = ref.get("type") - ref_url = ref.get("url") or "" - if not ref_url: - continue - if db and await get_llm_media_as_base64(db): - if ref_type == "image": - url = await media_to_base64(ref_url, "image/png") - elif ref_type == "video": - url = await media_to_base64(ref_url, "video/mp4") - else: - continue - else: - url = _absolute_url(ref_url) - if ref_type == "image": - parts.append({"type": "image_url", "image_url": {"url": url}}) - elif ref_type == "video": - parts.append({"type": "video_url", "video_url": {"url": url}}) - return parts - - -async def _get_model_config(db: AsyncSession) -> ModelConfig: - result = await db.execute( - select(ModelConfig) - .where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)) - .order_by(ModelConfig.priority.desc()) - .limit(1) - ) - config = result.scalar_one_or_none() - if not config: - raise ValueError("没有可用的ChatAPI模型配置") - if config.provider == "mock": - return config - if not config.api_base or not config.api_key or not config.model_name: - raise ValueError("ChatAPI模型配置不完整") - return config - - -async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask) -> tuple[str, dict]: - """Call ChatAPI once with current request params and attachments. No history context.""" - config_row = await _get_model_config(db) - if config_row.provider == "mock": - original_prompt = str(record.original_prompt or "") - await db.commit() - return original_prompt, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} - - user_content = await _build_user_content(record, db) - 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 ""), - api_key=str(config_row.api_key or ""), - model_name=str(config_row.model_name or ""), - max_tokens=config_row.max_tokens, - temperature=config_row.temperature, - ) - record = SimpleNamespace( - id=str(record.id), - user_id=str(record.user_id), - engine_id=str(record.engine_id or "") or None, - generation_mode=str(record.generation_mode or ""), - generation_attempt_no=int(record.generation_attempt_no or 1), - ) - # Release all configuration/media lookup reads before the remote request. - await db.commit() - - system_prompt = ( - "你是图片/视频生成提示词整理助手。你的职责是根据用户文字、上传图片/视频和生成参数," - "整理最终可直接用于生成模型的 prompt。不要声称你已经生成图片或视频,不要调用工具。" - "输出中文为主,内容具体、可执行,保留用户关键要求。" - ) - request_data = { - "model": config.model_name, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content}, - ], - "max_tokens": config.max_tokens, - "temperature": config.temperature, - } - started = time.perf_counter() - call_id = await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="request", - request_data=request_data, - module="generation_record", - step_code="prompt_optimize", - ) - async with provider_limit("ark_chat_prompt", settings.ARK_CHAT_PROMPT_MAX_CONCURRENCY): - async with httpx.AsyncClient(timeout=settings.CHATAPI_REQUEST_TIMEOUT_SECONDS) as client: - response: httpx.Response | None = None - try: - response = await client.post( - f"{config.api_base.rstrip('/')}/chat/completions", - headers={ - "Authorization": f"Bearer {config.api_key}", - "Content-Type": "application/json", - }, - json=request_data, - ) - latency_ms = int((time.perf_counter() - started) * 1000) - if response.status_code >= 400: - message = response.text[:5000] - await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="failed", - latency_ms=latency_ms, - http_status=response.status_code, - response_data=response.text, - error_message=message, - call_id=call_id, - module="generation_record", - step_code="prompt_optimize", - ) - raise RuntimeError(f"ChatAPI HTTP {response.status_code}: {message}") - data = response.json() - except Exception as exc: - if isinstance(exc, RuntimeError) and str(exc).startswith("ChatAPI HTTP "): - raise - latency_ms = int((time.perf_counter() - started) * 1000) - await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="failed", - latency_ms=latency_ms, - http_status=response.status_code if response is not None else None, - response_data=response.text if response is not None else None, - error_message=str(exc), - call_id=call_id, - module="generation_record", - step_code="prompt_optimize", - ) - raise - - usage = data.get("usage", {}) or {} - 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) - await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="success", - latency_ms=int((time.perf_counter() - started) * 1000), - http_status=response.status_code if response is not None else 200, - response_data=data, - prompt_tokens=input_tokens, - completion_tokens=output_tokens, - total_tokens=total_tokens, - call_id=call_id, - module="generation_record", - step_code="prompt_optimize", - ) - - content = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() - if not content: - await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="failed", - error_message="ChatAPI未返回有效prompt", - call_id=call_id, - module="generation_record", - step_code="prompt_optimize", - ) - raise RuntimeError("ChatAPI未返回有效prompt") - - token_usage_id = generate_id() - try: - db.add(TokenUsage( - id=token_usage_id, - model_config_id=config.id, - user_id=record.user_id, - owner_type="generation_record", - owner_id=record.id, - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=total_tokens, - )) - await db.flush() - except Exception as exc: - await log_provider_call( - record, - provider=config.provider, - api_type="chat_prompt", - model=config.model_name, - engine_id=record.engine_id, - status="failed", - error_message=f"token usage写入失败: {exc}", - call_id=call_id, - module="generation_record", - step_code="prompt_optimize", - ) - raise - return content, { - "token_usage_id": token_usage_id, - "model_config_id": config.id, - "model_config_name": config.name, - "model_provider": config.provider, - "model_name": config.model_name, - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "total_tokens": total_tokens, - } 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 ed9dad95..4ea4959d 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -54,7 +54,7 @@ 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_pre_deducted, + ensure_llm_charged, log_provider_failure, record_provider_exception, log_provider_start, @@ -62,7 +62,7 @@ from app.services.llm_billing import ( refund_on_final_failure, finalize_llm_business_failure, mark_business_success, - pre_deduct, + charge_llm_credits, ) 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, @@ -893,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 pre_deduct( + await charge_llm_credits( db, LlmBillingContext( user_id=str(project.user_id), @@ -1008,10 +1008,10 @@ async def run_image_prompt_optimize( description_prefix="爆款开头复刻图片AI提词优化", trace_id=f"hot-opening-image-prompt:{step_id_value}", ) - pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) - if not pre_deduct_validation.can_execute: + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -1327,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 pre_deduct( + await charge_llm_credits( db, LlmBillingContext( user_id=str(project.user_id), @@ -1440,10 +1440,10 @@ async def run_video_prompt_optimize( description_prefix="爆款开头复刻视频AI提词优化", trace_id=f"hot-opening-video-prompt:{step_id_value}", ) - pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) - if not pre_deduct_validation.can_execute: + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message diff --git a/video-gen-api/app/services/llm_billing/__init__.py b/video-gen-api/app/services/llm_billing/__init__.py index 9dcbb82c..1a85be8b 100644 --- a/video-gen-api/app/services/llm_billing/__init__.py +++ b/video-gen-api/app/services/llm_billing/__init__.py @@ -3,24 +3,24 @@ from app.services.llm_billing.context import ( LlmBillingContext, LlmBillingPolicy, LlmBillingStateError, - LlmPreDeductResult, - LlmPreDeductValidation, + LlmChargeResult, + LlmChargeValidation, ) from app.services.llm_billing.service import ( - ensure_pre_deducted, + charge_llm_credits, + ensure_llm_charged, + finalize_llm_business_failure, 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, - refund_on_final_failure, - finalize_llm_business_failure, mark_business_success, - pre_deduct, + record_provider_exception, + refund_on_final_failure, validate_retryable_previous_attempt, ) @@ -29,10 +29,10 @@ __all__ = [ "LlmBillingContext", "LlmBillingPolicy", "LlmBillingStateError", - "LlmPreDeductResult", - "LlmPreDeductValidation", - "pre_deduct", - "ensure_pre_deducted", + "LlmChargeResult", + "LlmChargeValidation", + "charge_llm_credits", + "ensure_llm_charged", "get_llm_ledger_states", "validate_retryable_previous_attempt", "log_provider_start", 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 index cd928bc6..8e265785 100644 --- a/video-gen-api/app/services/llm_billing/call_audit_service.py +++ b/video-gen-api/app/services/llm_billing/call_audit_service.py @@ -6,7 +6,7 @@ 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.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingExecutionStatus, LlmCallAttemptStatus from app.models.base import AsyncSessionLocal from app.models.credit_record import CreditRecord from app.models.llm_billing.call_attempt import LlmCallAttempt @@ -14,9 +14,35 @@ 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.services.operation_log_service import log_operation_event from app.utils.id_gen import generate_id +def _audit_log(ctx: LlmBillingContext, event: LlmBillingEvent, **detail: Any) -> None: + log_operation_event( + domain=LlmBillingDomain.LLM_BILLING.value, + module=ctx.source_module or "llm", + event_type=event.value, + event_status="success", + trace_id=ctx.trace_id, + request_id=ctx.request_id, + user_id=ctx.user_id, + project_id=ctx.source_project_id, + task_id=ctx.celery_task_id, + step_id=ctx.source_step_id, + message=ctx.description_prefix or ctx.billing_scene or "LLM调用审计", + detail={ + "scene_code": ctx.billing_scene, + "owner_type": ctx.owner_type, + "owner_id": ctx.owner_id, + "attempt_no": ctx.attempt_no, + "billing_execution_id": ctx.billing_execution_id, + "call_attempt_id": ctx.current_call_attempt_id, + **detail, + }, + ) + + def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int: for key in keys: value = (usage or {}).get(key) @@ -43,11 +69,12 @@ async def create_call_attempt( ) execution = result.scalar_one_or_none() if execution is None: - raise LlmBillingStateError("LLM固定预扣执行记录不存在,禁止调用模型") + 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.CHARGED.value, + LlmBillingExecutionStatus.PRE_DEDUCTED.value, # 历史兼容 LlmBillingExecutionStatus.PROCESSING.value, }: raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}") @@ -90,7 +117,7 @@ async def _get_or_create_token_usage( input_tokens: int, output_tokens: int, total_tokens: int, -) -> TokenUsage: +) -> tuple[TokenUsage, bool]: """先持久化 TokenUsage,再允许任何外键引用它。 LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy @@ -105,7 +132,7 @@ async def _get_or_create_token_usage( ) existing = existing_result.scalar_one_or_none() if existing is not None: - return existing + return existing, False usage_row = TokenUsage( id=generate_id(), @@ -136,8 +163,8 @@ async def _get_or_create_token_usage( existing = existing_result.scalar_one_or_none() if existing is None: raise - return existing - return usage_row + return existing, False + return usage_row, True async def finish_call_success( @@ -167,7 +194,7 @@ async def finish_call_success( ctx.token_usage_id = attempt.token_usage_id return previous_status = attempt.status - usage_row = await _get_or_create_token_usage( + usage_row, token_usage_created = await _get_or_create_token_usage( db, ctx=ctx, attempt=attempt, @@ -222,8 +249,15 @@ async def finish_call_success( 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 + _audit_log( + ctx, + LlmBillingEvent.TOKEN_USAGE_CREATED if token_usage_created else LlmBillingEvent.TOKEN_USAGE_REUSED, + token_usage_id=token_usage_id, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str: diff --git a/video-gen-api/app/services/llm_billing/config.py b/video-gen-api/app/services/llm_billing/config.py index e1e2ca12..f4f5597d 100644 --- a/video-gen-api/app/services/llm_billing/config.py +++ b/video-gen-api/app/services/llm_billing/config.py @@ -12,11 +12,11 @@ async def get_llm_billing_policy( *, scene_code: str | None = None, ) -> LlmBillingPolicy: - """按业务场景读取固定预扣;不再读取旧 SystemConfig 冻结配置。""" + """按业务场景读取固定消费积分;不再读取旧 SystemConfig 冻结/预扣配置。""" resolved_scene = scene_code if not resolved_scene: return LlmBillingPolicy( - pre_deduct_credits=0.0, + charge_credits=0.0, valid=False, error="LLM业务场景不能为空", ) @@ -28,25 +28,25 @@ async def get_llm_billing_policy( model = result.scalar_one_or_none() if model is None: return LlmBillingPolicy( - pre_deduct_credits=0.0, + charge_credits=0.0, valid=False, - error=f"未配置LLM场景固定预扣积分:{resolved_scene}", + error=f"未配置LLM场景消费积分:{resolved_scene}", ) + # 数据库物理字段 pre_deduct_credits 为历史命名,本轮不迁移字段;业务语义统一为 charge_credits。 amount = float(model.pre_deduct_credits) if not model.is_active or amount <= 0: return LlmBillingPolicy( - pre_deduct_credits=amount, + charge_credits=amount, valid=False, - error=f"LLM场景固定预扣配置未启用或金额无效:{resolved_scene}", + error=f"LLM场景积分配置未启用或金额无效:{resolved_scene}", policy_id=model.id, version=model.version, scene_name=model.scene_name, ) return LlmBillingPolicy( - pre_deduct_credits=round(amount, 2), + charge_credits=round(amount, 2), valid=True, policy_id=model.id, version=model.version, scene_name=model.scene_name, ) - diff --git a/video-gen-api/app/services/llm_billing/context.py b/video-gen-api/app/services/llm_billing/context.py index 2bcbf63e..2c799877 100644 --- a/video-gen-api/app/services/llm_billing/context.py +++ b/video-gen-api/app/services/llm_billing/context.py @@ -9,11 +9,11 @@ from app.services.generation.billing_service import build_credit_biz_key class LlmBillingConfigurationError(RuntimeError): - """LLM 场景固定预扣配置无效。""" + """LLM 场景积分配置无效。""" class LlmBillingStateError(RuntimeError): - """业务 attempt 的固定预扣状态不允许继续执行。""" + """业务 attempt 的 LLM 积分消费状态不允许继续执行。""" class LlmProviderPostprocessError(RuntimeError): @@ -26,7 +26,7 @@ class LlmProviderPostprocessError(RuntimeError): @dataclass(slots=True, frozen=True) class LlmBillingPolicy: - pre_deduct_credits: float + charge_credits: float valid: bool = True error: str | None = None policy_id: str | None = None @@ -65,13 +65,15 @@ class LlmBillingContext: current_call_attempt_id: str | None = None @property - def pre_deduct_biz_key(self) -> str: + 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="pre_deduct", + # 使用独立 llm_charge 幂等键,避免与历史 Token 按量计费的 :charge 流水碰撞; + # CreditRecord 的业务动作仍然记录为 charge(真实消费)。 + action="llm_charge", ) @property @@ -86,11 +88,11 @@ class LlmBillingContext: @property def billing_biz_key(self) -> str: - return self.pre_deduct_biz_key + return self.charge_biz_key @dataclass(slots=True, frozen=True) -class LlmPreDeductResult: +class LlmChargeResult: amount: float state: LlmBillingLedgerState created: bool = False @@ -100,10 +102,10 @@ class LlmPreDeductResult: @dataclass(slots=True, frozen=True) -class LlmPreDeductValidation: +class LlmChargeValidation: can_execute: bool amount: float state: LlmBillingLedgerState reason: str | None = None - pre_deduct_record_id: str | None = None + charge_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 index cdfadddf..2c44c3fd 100644 --- a/video-gen-api/app/services/llm_billing/query_service.py +++ b/video-gen-api/app/services/llm_billing/query_service.py @@ -1,12 +1,32 @@ from __future__ import annotations -from sqlalchemy import func, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.enums.llm_billing import LlmBillingExecutionStatus from app.models.llm_billing.call_attempt import LlmCallAttempt from app.models.llm_billing.execution import LlmBillingExecution +def _normalized_execution_status(status: str) -> str: + if status == LlmBillingExecutionStatus.PRE_DEDUCTED.value: + return LlmBillingExecutionStatus.CHARGED.value + return status + + +def _execution_status_filter(status: str): + # 新接口只暴露 charged;查询时同时兼容历史 pre_deducted 数据。 + if status in { + LlmBillingExecutionStatus.CHARGED.value, + LlmBillingExecutionStatus.PRE_DEDUCTED.value, + }: + return or_( + LlmBillingExecution.status == LlmBillingExecutionStatus.CHARGED.value, + LlmBillingExecution.status == LlmBillingExecutionStatus.PRE_DEDUCTED.value, + ) + return LlmBillingExecution.status == status + + async def list_executions_with_calls( db: AsyncSession, *, @@ -20,9 +40,10 @@ async def list_executions_with_calls( if scene_code: filters.append(LlmBillingExecution.scene_code == scene_code) if status: - filters.append(LlmBillingExecution.status == status) + filters.append(_execution_status_filter(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( @@ -33,22 +54,29 @@ async def list_executions_with_calls( .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: + + # 先收集 execution ids,再一次批查所有 CallAttempt 并按 execution_id 回填,避免 N+1。 + execution_ids = [item.id for item in executions] + calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in execution_ids} + if execution_ids: call_result = await db.execute( select(LlmCallAttempt) - .where(LlmCallAttempt.billing_execution_id.in_(ids)) + .where(LlmCallAttempt.billing_execution_id.in_(execution_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 = [] + + items: list[dict] = [] for execution in executions: + # 数据库物理字段 pre_deduct_credits 为历史命名;Admin/API 统一只暴露 charge_credits。 data = { column.name: getattr(execution, column.name) for column in LlmBillingExecution.__table__.columns + if column.name != "pre_deduct_credits" } + data["charge_credits"] = execution.pre_deduct_credits + data["status"] = _normalized_execution_status(execution.status) 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 7566173a..24388d17 100644 --- a/video-gen-api/app/services/llm_billing/service.py +++ b/video-gen-api/app/services/llm_billing/service.py @@ -1,14 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import replace from typing import Any, Iterable, Mapping -from sqlalchemy import and_, or_, select +from sqlalchemy import select, tuple_ from sqlalchemy.ext.asyncio import AsyncSession from app.enums.credit_record import ( CREDIT_RECORD_BILLING_SCENE_LABELS, CreditRecordAction, + CreditRecordChargeKind, CreditRecordSubject, ) from app.enums.llm_billing import ( @@ -37,18 +38,19 @@ from app.services.llm_billing.context import ( LlmBillingContext, LlmBillingStateError, LlmProviderPostprocessError, - LlmPreDeductResult, - LlmPreDeductValidation, + LlmChargeResult, + LlmChargeValidation, ) 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 _ExecutionState: - execution: LlmBillingExecution | None - state: LlmBillingLedgerState - reason: str | None = None + +def _credit_subject(ctx: LlmBillingContext) -> str: + if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value: + return CreditRecordSubject.ANALYSIS.value + return CreditRecordSubject.TEXT.value def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str: @@ -122,13 +124,21 @@ async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None: 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) + 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) + # CallAttempt/TokenUsage 使用独立短事务更新 execution 聚合值;主 Session + # 可能在 commit 前后仍持有同一对象。强制回填数据库最新标量,避免 + # expire_on_commit=False 下 identity map 返回旧 Token/调用次数。 + .execution_options(populate_existing=True) + ) if for_update: stmt = stmt.with_for_update() result = await db.execute(stmt) @@ -139,6 +149,7 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed if execution is None: return LlmBillingLedgerState.MISSING mapping = { + LlmBillingExecutionStatus.CHARGED.value: LlmBillingLedgerState.ACTIVE, LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE, LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE, LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED, @@ -149,13 +160,15 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed 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") +async def charge_llm_credits(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeResult: + """在业务 API 事务内按后台场景配置同步真实消费积分。""" + _log(ctx, LlmBillingEvent.CHARGE_START, status="started") if not ctx.billing_scene: - raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene") - # 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求 - # 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。 + _log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error="billing_scene_missing") + raise LlmBillingConfigurationError("LLM积分消费必须指定billing_scene") + + # 同一 user + scene + owner + attempt 的幂等判断必须位于用户积分锁之后, + # 避免并发 API 请求同时看到 missing 后重复消费或撞 execution 唯一键。 await acquire_user_credit_lock(db, ctx.user_id) existing = await _find_execution(db, ctx, for_update=True) if existing: @@ -163,22 +176,29 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe ctx.model_config_id = existing.model_config_id ctx.model_name = existing.model_name_snapshot ctx.provider = existing.provider_snapshot - return LlmPreDeductResult( + ctx.model_parameters_snapshot = existing.model_parameters_snapshot + state = _state_for_execution(existing) + _log( + ctx, + LlmBillingEvent.CHARGE_IDEMPOTENT_HIT, amount=to_float(existing.pre_deduct_credits), - state=_state_for_execution(existing), + credit_record_id=existing.credit_record_id, + execution_status=existing.status, + ledger_state=state.value, + ) + return LlmChargeResult( + amount=to_float(existing.pre_deduct_credits), + state=state, 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, - ) + 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固定预扣配置无效") + _log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error=policy.error) + raise LlmBillingConfigurationError(policy.error or "LLM场景积分配置无效") scene_name = _scene_name(ctx, policy.scene_name) meta = { @@ -186,8 +206,8 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe "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, + "charge_action": CreditRecordAction.CHARGE.value, + "credit_subject": _credit_subject(ctx), "billing_scene": ctx.billing_scene, "scene_name_snapshot": scene_name, "source_module": ctx.source_module, @@ -199,19 +219,31 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe "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(), - ) + try: + mutation = await deduct_credits_result( + db, + user_id=ctx.user_id, + amount=policy.charge_credits, + description=f"{scene_name}积分消费", + related_id=ctx.related_id or ctx.owner_id, + biz_key=ctx.charge_biz_key, + record_meta=meta, + record_type="consume", + request_time=ctx.request_time or utc_now(), + ) + except InsufficientCreditsError: + _log( + ctx, + LlmBillingEvent.CHARGE_INSUFFICIENT, + status="failed", + error="insufficient_credits", + amount=policy.charge_credits, + ) + raise + if mutation.record is None: - raise LlmBillingStateError("LLM固定预扣流水创建失败") + raise LlmBillingStateError("LLM积分消费流水创建失败") + execution = LlmBillingExecution( id=generate_id(), user_id=ctx.user_id, @@ -227,18 +259,27 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe 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, + # 数据库物理字段仍沿用历史 pre_deduct_credits 命名;语义已经是本 attempt 真实消费积分。 + pre_deduct_credits=policy.charge_credits, credit_record_id=mutation.record.id, - status=LlmBillingExecutionStatus.PRE_DEDUCTED.value, + status=LlmBillingExecutionStatus.CHARGED.value, ) db.add(execution) await db.flush() + + # flush 后只使用已加载标量,不访问 relationship,避免 commit 后懒加载/MissingGreenlet 风险。 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, + _log( + ctx, + LlmBillingEvent.CHARGE_SUCCESS, + amount=policy.charge_credits, + credit_record_id=mutation.record.id, + created=mutation.created, + ) + return LlmChargeResult( + amount=policy.charge_credits, state=LlmBillingLedgerState.ACTIVE, created=mutation.created, record_id=mutation.record.id, @@ -246,10 +287,18 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe ) -async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation: +async def ensure_llm_charged(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation: + _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started") execution = await _find_execution(db, ctx) if execution is None: - return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing") + _log( + ctx, + LlmBillingEvent.EXECUTION_BLOCKED, + status="failed", + error="charge_missing", + ledger_state=LlmBillingLedgerState.MISSING.value, + ) + return LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "charge_missing") ctx.billing_execution_id = execution.id ctx.model_config_id = execution.model_config_id ctx.model_name = execution.model_name_snapshot @@ -261,68 +310,73 @@ async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPr _log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status) else: _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status) - return LlmPreDeductValidation( + return LlmChargeValidation( 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, + charge_record_id=execution.credit_record_id, execution_id=execution.id, ) -async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]: +async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmChargeValidation]: 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, - ) + + keys = list(dict.fromkeys( + (ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no) for ctx in items - ] - result = await db.execute(select(LlmBillingExecution).where(or_(*conditions))) + )) + result = await db.execute( + select(LlmBillingExecution).where( + tuple_( + LlmBillingExecution.user_id, + LlmBillingExecution.scene_code, + LlmBillingExecution.owner_type, + LlmBillingExecution.owner_id, + LlmBillingExecution.business_attempt_no, + ).in_(keys) + ) + ) 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] = {} + output: dict[str, LlmChargeValidation] = {} 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( + output[ctx.charge_biz_key] = LlmChargeValidation( 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, + charge_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: +async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation: 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] + validation = (await get_llm_ledger_states(db, [previous]))[previous.charge_biz_key] if ctx.attempt_no <= 1: - return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt") + return LlmChargeValidation(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) + return LlmChargeValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.charge_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) + validation = await ensure_llm_charged(db, ctx) if not validation.can_execute: - raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM") + raise LlmBillingStateError("缺少有效LLM积分消费记录,禁止调用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 {})) @@ -401,7 +455,7 @@ async def mark_business_success( await finish_call_success(ctx, usage=usage) execution = await _find_execution(db, ctx, for_update=True) if execution is None: - raise LlmBillingStateError("LLM固定预扣执行记录不存在") + 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}: @@ -427,7 +481,7 @@ async def mark_business_success( 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) + _log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, charge_credits=to_float(execution.pre_deduct_credits), idempotent=not created) return BillingSummary( record_id=ctx.owner_id, user_id=ctx.user_id, @@ -447,7 +501,7 @@ async def mark_business_success( async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary: from app.services.credit.ledger_service import refund_consumption - # 与固定预扣保持 user advisory -> execution -> balance 的统一锁顺序。 + # 与真实消费保持 user advisory -> execution -> original record -> balance 的统一锁顺序。 await acquire_user_credit_lock(db, ctx.user_id) execution = await _find_execution(db, ctx, for_update=True) if execution is None: @@ -459,21 +513,47 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e ctx.user_id, [BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)], ) - if execution.status == LlmBillingExecutionStatus.SUCCEEDED.value: - # 调用方只有在业务最终失败时才能进这里;允许从业务成功调用记录转最终失败退款。 - pass + + original_result = await db.execute( + select(CreditRecord) + .where(CreditRecord.id == execution.credit_record_id, CreditRecord.user_id == ctx.user_id) + .limit(1) + .with_for_update() + ) + original_record = original_result.scalar_one_or_none() + if original_record is None or not original_record.biz_key: + execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value + execution.final_error_message = "原LLM积分消费流水不存在或缺少BizKey" + execution.completed_at = utc_now() + _log( + ctx, + LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, + status="failed", + error=execution.final_error_message, + credit_record_id=execution.credit_record_id, + ) + raise LlmBillingStateError(execution.final_error_message) + execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value execution.final_error_message = str(error or "业务最终失败")[:5000] execution.completed_at = utc_now() - _log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error) + original_biz_key = original_record.biz_key + _log( + ctx, + LlmBillingEvent.FINAL_FAILURE_START, + status="started", + error=error, + credit_record_id=original_record.id, + original_biz_key=original_biz_key, + ) try: refunded = await refund_consumption( db, user_id=ctx.user_id, - refund_for_biz_key=ctx.pre_deduct_biz_key, + refund_for_biz_key=original_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", + biz_key=f"{original_biz_key}:business-final-refund", record_meta={ "owner_type": ctx.owner_type, "owner_id": ctx.owner_id, @@ -500,6 +580,7 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS, refund_available=to_float(refunded.available_amount), refund_expired=to_float(refunded.expired_amount), + credit_record_id=original_record.id, ) return BillingSummary( ctx.owner_id, @@ -508,15 +589,10 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e ) except Exception as exc: execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value - _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc)) + _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), credit_record_id=original_record.id) 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, *, 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 e624c998..5045f6d1 100644 --- a/video-gen-api/app/services/module_async_recovery_service.py +++ b/video-gen-api/app/services/module_async_recovery_service.py @@ -38,7 +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, LlmPreDeductValidation, get_llm_ledger_states +from app.services.llm_billing import LlmBillingContext, LlmChargeValidation, get_llm_ledger_states from app.tasks.celery_app import celery_app logger = logging.getLogger("video_gen") @@ -92,7 +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), - description_prefix="模块AI提词优化", + description_prefix="模块AI提词优化", trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}", ) @@ -100,24 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext: async def _load_step_billing_validations( db: AsyncSession, steps: Iterable[ModuleGenerationStep], -) -> dict[str, LlmPreDeductValidation]: +) -> dict[str, LlmChargeValidation]: step_list = list(steps) if not step_list: return {} contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list} - # 固定预扣已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录, - # 不再读取旧 SystemConfig 冻结配置;缺少固定预扣记录时直接终止恢复。 + # 场景积分消费已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录, + # 不再读取旧 SystemConfig 冻结配置;缺少场景积分消费记录时直接终止恢复。 ledger_states = await get_llm_ledger_states(db, contexts.values()) - output: dict[str, LlmPreDeductValidation] = {} + output: dict[str, LlmChargeValidation] = {} for step_id, ctx in contexts.items(): ledger = ledger_states.get( - ctx.pre_deduct_biz_key, - LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), + ctx.charge_biz_key, + LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), ) if ledger.state == LlmBillingLedgerState.ACTIVE: output[step_id] = ledger elif ledger.state == LlmBillingLedgerState.MISSING: - output[step_id] = LlmPreDeductValidation( + output[step_id] = LlmChargeValidation( 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 90d9ff28..2781bd6c 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 @@ -50,12 +50,12 @@ 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_pre_deducted, + ensure_llm_charged, log_provider_failure, log_provider_start, log_provider_success, mark_business_success, - pre_deduct, + charge_llm_credits, record_provider_exception, finalize_llm_business_failure, ) @@ -432,7 +432,7 @@ async def create_hot_opening_project_v2( video_config=video_config, target_platform=req.target_platform or "抖音", ) - await pre_deduct( + await charge_llm_credits( db, build_v2_video_prompt_billing_context( user_id=str(current_user.id), @@ -599,7 +599,7 @@ async def create_shot_replicate_project_v2( urls=[req.material_image_url], allow_common_migrate=True, ) - await pre_deduct( + await charge_llm_credits( db, build_v2_video_prompt_billing_context( user_id=str(current_user.id), @@ -723,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 pre_deduct( + await charge_llm_credits( db, build_v2_video_prompt_billing_context( user_id=str(project.user_id), @@ -887,10 +887,10 @@ async def run_video_prompt_optimize_v2( description_prefix=f"{config.display_name}视频提词优化", trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}", ) - pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) - if not pre_deduct_validation.can_execute: + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务" step.completed_at = utc_now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_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 73734180..e2ad97da 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -58,7 +58,7 @@ 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_pre_deducted, + ensure_llm_charged, log_provider_failure, record_provider_exception, log_provider_start, @@ -66,7 +66,7 @@ from app.services.llm_billing import ( refund_on_final_failure, finalize_llm_business_failure, mark_business_success, - pre_deduct, + charge_llm_credits, ) 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, @@ -842,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 pre_deduct( + await charge_llm_credits( db, LlmBillingContext( user_id=str(project.user_id), @@ -957,10 +957,10 @@ async def run_image_prompt_optimize( description_prefix="拆镜复刻图片AI提词优化", trace_id=f"shot-image-prompt:{step_id_value}", ) - pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) - if not pre_deduct_validation.can_execute: + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message @@ -1286,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 pre_deduct( + await charge_llm_credits( db, LlmBillingContext( user_id=str(project.user_id), @@ -1399,10 +1399,10 @@ async def run_video_prompt_optimize( description_prefix="拆镜复刻视频AI提词优化", trace_id=f"shot-video-prompt:{step_id_value}", ) - pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) - if not pre_deduct_validation.can_execute: + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: step.status = ModuleStepStatusEnum.FAILED.value - step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" + step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务" step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = step.error_message 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 1c080eb9..ebaaee0f 100644 --- a/video-gen-api/app/services/shot_replicate_recovery_service.py +++ b/video-gen-api/app/services/shot_replicate_recovery_service.py @@ -233,7 +233,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: *(build_task_set_analysis_billing_context(item) for item in task_sets), *(build_segment_analysis_billing_context(item) for item in segments), ] - # 固定预扣配置变更后仍需识别并继续处理已存在的有效预扣;缺少预扣记录时禁止恢复。 + # 场景积分消费配置变更后仍需识别并继续处理已存在的有效消费;缺少消费记录时禁止恢复。 billing_states = await get_llm_ledger_states(db, billing_contexts) lock_keys: list[str] = [] @@ -259,7 +259,7 @@ 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.pre_deduct_biz_key) + validation = billing_states.get(context.charge_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: @@ -289,7 +289,7 @@ 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.pre_deduct_biz_key) + validation = billing_states.get(context.charge_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: 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 89b5d4b5..1a971ae4 100644 --- a/video-gen-api/app/services/shot_replicate_taskset_service.py +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -56,7 +56,7 @@ from app.services.module_generation_log_service import log_module_event_file from app.services.llm_billing import ( LlmBillingContext, refund_on_final_failure, - pre_deduct, + charge_llm_credits, validate_retryable_previous_attempt, ) from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source @@ -244,7 +244,7 @@ async def create_task_set( ) existing = existing_result.scalar_one_or_none() if existing: - # 幂等命中只返回已有任务,不重复预扣、绑定资源或投递 Celery。 + # 幂等命中只返回已有任务,不重复消费积分、绑定资源或投递 Celery。 return existing, False asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds) @@ -265,7 +265,7 @@ async def create_task_set( ) db.add(task_set) await db.flush() - await pre_deduct(db, build_task_set_analysis_billing_context(task_set)) + await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type="SHOT_TASK_SET_CREATED", @@ -683,7 +683,7 @@ async def prepare_retry_split_segment( await refresh_task_set_split_summary(db, task_set.id) await db.flush() - # 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复预扣。 + # 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复消费积分。 # 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。 log_module_event_file( module=MODULE, @@ -748,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 pre_deduct(db, build_segment_analysis_billing_context(segment)) + await charge_llm_credits(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 +920,7 @@ async def delete_segment( await refund_on_final_failure( db, build_segment_analysis_billing_context(segment), - error="用户删除自定义拆镜片段,释放未结算的片段分析预扣", + error="用户删除自定义拆镜片段,退回片段分析消费积分", ) deleted_at = _now() @@ -979,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_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, + "llm_charge_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, }, ) @@ -1037,14 +1037,14 @@ async def delete_task_set( await refund_on_final_failure( db, build_task_set_analysis_billing_context(task_set), - error="用户删除拆镜任务集,释放未结算的原视频分析预扣", + error="用户删除拆镜任务集,退回原视频分析消费积分", ) for segment in segments: if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: await refund_on_final_failure( db, build_segment_analysis_billing_context(segment), - error="用户删除拆镜任务集,释放未结算的片段分析预扣", + error="用户删除拆镜任务集,退回片段分析消费积分", ) segment_ids = [segment.id for segment in segments] @@ -1110,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_pre_deduct_refund_on_cancel": True, + "llm_charge_refund_on_cancel": True, }, ) return ShotTaskSetDeleteOut( @@ -1195,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 pre_deduct(db, build_task_set_analysis_billing_context(task_set)) + await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value, @@ -1301,7 +1301,7 @@ async def prepare_reanalyze_segment( segment.segment_category = None segment.segment_audience = None await db.flush() - await pre_deduct(db, build_segment_analysis_billing_context(segment)) + await charge_llm_credits(db, build_segment_analysis_billing_context(segment)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value, @@ -1420,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 的冻结状态。用户重试切片时继续沿用 - # 原固定预扣;只有片段分析最终失败或用户删除片段时才按原来源退款。 + # 原场景积分消费;只有片段分析最终失败或用户删除片段时才按原来源退款。 log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py index 13f186a2..bffd0cfb 100644 --- a/video-gen-api/app/tasks/shot_replicate_tasks.py +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -39,7 +39,7 @@ 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_pre_deducted, + ensure_llm_charged, log_provider_failure, log_provider_start, log_provider_success, @@ -474,9 +474,9 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int description_prefix="拆镜复刻原视频分析", trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}", ) - 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}),已终止原视频分析任务" + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: + error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止原视频分析任务" task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value task_set.analysis_claim_token = None @@ -746,9 +746,9 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no description_prefix="拆镜复刻片段视频分析", trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", ) - 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}),已终止片段视频分析任务" + charge_validation = await ensure_llm_charged(db, llm_billing_context) + if not charge_validation.can_execute: + error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止片段视频分析任务" segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value segment.analysis_claim_token = None segment.analysis_lease_until = None @@ -957,7 +957,7 @@ async def _mark_auto_segment_analysis_dispatch_failed( 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, @@ -1216,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 - # 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用 + # 切片最终失败不退回片段分析场景积分消费;手动切片重试继续沿用 # 原 attempt。用户最终删除片段/任务集时再做取消补偿。 else: segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value diff --git a/video-gen-app/src/api/mock.ts b/video-gen-app/src/api/mock.ts index 5c479178..d30e8f57 100644 --- a/video-gen-app/src/api/mock.ts +++ b/video-gen-app/src/api/mock.ts @@ -207,7 +207,7 @@ export async function mockOptimizePrompt( await delay(1500); const project = MOCK_PROJECTS.find((p) => p.id === projectId); - // Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。 + // Mock 与正式接口保持一致:提示词优化按后台场景配置直接消费积分,积分不足同步拦截。 const cost = 5; if (currentUser) { diff --git a/video-gen-app/src/pages/GeneratePage.tsx b/video-gen-app/src/pages/GeneratePage.tsx index 10c6b823..eb42e035 100644 --- a/video-gen-app/src/pages/GeneratePage.tsx +++ b/video-gen-app/src/pages/GeneratePage.tsx @@ -3200,7 +3200,7 @@ const GeneratePage: React.FC = () => { )}