From 68e902b4a489aac8cc5e041898124be9c1dcb957 Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Fri, 24 Jul 2026 09:18:05 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=AF=E5=88=86=E5=86=BB=E7=BB=93=E9=87=8A?= =?UTF-8?q?=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-admin/src/api/index.ts | 1 + .../src/pages/AdminCreditRecords.tsx | 27 +- video-gen-admin/src/pages/AdminSettings.tsx | 109 ++- video-gen-admin/src/types/index.ts | 2 + ..._repair_generation_record_frozen_config.py | 772 +++++++++++++++ video-gen-api/app/api/v1/admin.py | 32 +- video-gen-api/app/api/v1/generation.py | 211 ++--- .../app/api/v1/hot_opening_replicate.py | 234 ++++- video-gen-api/app/api/v1/shot_replicate.py | 446 ++++++++- .../app/api/v2/hot_opening_replicate.py | 51 +- video-gen-api/app/api/v2/shot_replicate.py | 51 +- video-gen-api/app/enums/common.py | 8 + video-gen-api/app/enums/credit_record.py | 10 + video-gen-api/app/enums/llm_billing.py | 56 ++ video-gen-api/app/main.py | 6 + video-gen-api/app/schemas/admin.py | 1 + video-gen-api/app/schemas/shot_replicate.py | 1 + .../services/admin_credit_record_service.py | 25 +- .../services/credit_record_meta_service.py | 17 +- video-gen-api/app/services/credits.py | 212 ++++- .../services/generation/billing_service.py | 58 +- .../services/hot_opening_replicate_service.py | 267 +++++- .../hot_opening_video_prompt_service.py | 33 +- video-gen-api/app/services/llm.py | 20 +- .../app/services/llm_billing/__init__.py | 43 + .../app/services/llm_billing/config.py | 145 +++ .../app/services/llm_billing/context.py | 113 +++ .../app/services/llm_billing/service.py | 882 ++++++++++++++++++ .../services/module_async_recovery_service.py | 167 +++- .../module_generation_v2/dispatch_service.py | 14 + .../module_generation_v2/flow_service.py | 179 +++- .../app/services/operation_log_service.py | 115 ++- .../services/shot_replicate_flow_service.py | 261 +++++- .../shot_replicate_recovery_service.py | 57 +- .../shot_replicate_taskset_service.py | 213 ++++- .../services/shot_video_analysis_service.py | 27 +- .../app/services/system_config_cache.py | 90 ++ .../app/tasks/shot_replicate_tasks.py | 178 +++- 38 files changed, 4743 insertions(+), 391 deletions(-) create mode 100644 video-gen-api/alembic/versions/6a3ea8d0b4c8_repair_generation_record_frozen_config.py create mode 100644 video-gen-api/app/enums/llm_billing.py create mode 100644 video-gen-api/app/services/llm_billing/__init__.py create mode 100644 video-gen-api/app/services/llm_billing/config.py create mode 100644 video-gen-api/app/services/llm_billing/context.py create mode 100644 video-gen-api/app/services/llm_billing/service.py create mode 100644 video-gen-api/app/services/system_config_cache.py diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 81e9875d..eb283323 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -326,6 +326,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): setMaybe(params, 'credit_subject', filters?.creditSubject); setMaybe(params, 'media_type', filters?.mediaType); setMaybe(params, 'charge_kind', filters?.chargeKind); + setMaybe(params, 'charge_action', filters?.chargeAction); setMaybe(params, 'source_module', filters?.sourceModule); setMaybe(params, 'source_step_code', filters?.sourceStepCode); setMaybe(params, 'billing_scene', filters?.billingScene); diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx index 2c3c3349..312d65e2 100644 --- a/video-gen-admin/src/pages/AdminCreditRecords.tsx +++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx @@ -38,6 +38,14 @@ const RECORD_TYPE_MAP: Record }, }; + +const CHARGE_ACTION_MAP: Record = { + charge: { text: '真实扣费', color: 'red' }, + refund: { text: '真实退款', color: 'blue' }, + hold: { text: '预扣占用', color: 'gold' }, + hold_release: { text: '预扣释放', color: 'green' }, +}; + const userScopeOptions = [ { value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' }, @@ -85,6 +93,15 @@ const chargeKindOptions = [ { value: 'team_internal', label: '团队内部转移' }, ]; + +const chargeActionOptions = [ + { value: '', label: '全部交易动作' }, + { value: 'charge', label: '真实扣费' }, + { value: 'refund', label: '真实退款' }, + { value: 'hold', label: '预扣占用' }, + { value: 'hold_release', label: '预扣释放' }, +]; + const sourceModuleOptions = [ { value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, @@ -171,6 +188,7 @@ const AdminCreditRecords: React.FC = () => { const [creditSubject, setCreditSubject] = useState(''); const [mediaType, setMediaType] = useState(''); const [chargeKind, setChargeKind] = useState(''); + const [chargeAction, setChargeAction] = useState(''); const [sourceModule, setSourceModule] = useState(''); const [sourceStepCode, setSourceStepCode] = useState(''); const [billingScene, setBillingScene] = useState(''); @@ -186,13 +204,14 @@ const AdminCreditRecords: React.FC = () => { creditSubject: creditSubject || undefined, mediaType: mediaType || undefined, chargeKind: chargeKind || undefined, + chargeAction: chargeAction || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined, billingScene: billingScene || undefined, startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'), ...buildScope(userScope), - }), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]); + }), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]); const load = async () => { setLoading(true); @@ -221,6 +240,7 @@ const AdminCreditRecords: React.FC = () => { setCreditSubject(''); setMediaType(''); setChargeKind(''); + setChargeAction(''); setSourceModule(''); setSourceStepCode(''); setBillingScene(''); @@ -255,6 +275,7 @@ const AdminCreditRecords: React.FC = () => { { title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' }, { title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' }, { title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' }, + { title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') }, { title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' }, { title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' }, @@ -321,6 +342,7 @@ const AdminCreditRecords: React.FC = () => { { title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => {r.userTypeLabel || '-'} }, { title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? {v} : 未分配 }, { title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return {cfg.text}; } }, + { title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? {r.chargeActionLabel || cfg.text} : 历史; } }, { title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => {v || '-'} }, { title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' }, { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => {r.billingSceneLabel || '-'}{r.sourceStepCodeLabel || '-'} }, @@ -368,6 +390,7 @@ const AdminCreditRecords: React.FC = () => { { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} /> { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} /> { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} /> + { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} /> { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} /> { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} /> { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} /> @@ -394,7 +417,7 @@ const AdminCreditRecords: React.FC = () => { showSizeChanger: true, showTotal: (t) => `共 ${t} 条记录`, }} - scroll={{ x: 2050 }} + scroll={{ x: 2160 }} /> diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx index 9951261d..1325031d 100644 --- a/video-gen-admin/src/pages/AdminSettings.tsx +++ b/video-gen-admin/src/pages/AdminSettings.tsx @@ -48,8 +48,14 @@ const AdminSettings: React.FC = () => { setConfigs(data); const formValues: Record = {}; data.forEach(c => { formValues[c.key] = c.value; }); - // 预扣积分默认值 + // LLM 预扣积分默认值 if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5'; + if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true'; + if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5'; + if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5'; + if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5'; + if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10'; + if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10'; formValues.resource_capacity_enabled = capacity.enabled; formValues.resource_capacity_limit_value = capacity.limitValue || '1.000'; formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB'; @@ -64,25 +70,80 @@ const AdminSettings: React.FC = () => { const handleSave = async () => { try { const values = await form.validateFields(); + const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes( + String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(), + ); + if (llmBillingEnabled) { + const holdKeys = [ + 'optimize_hold_credits', + 'llm_hold_credits_default', + 'llm_hold_credits_generation_record_prompt', + 'llm_hold_credits_module_image_prompt', + 'llm_hold_credits_module_video_prompt', + 'llm_hold_credits_shot_video_analysis', + ]; + const invalidKey = holdKeys.find((key) => { + const numericValue = Number(values[key]); + return !Number.isFinite(numericValue) || numericValue <= 0; + }); + if (invalidKey) { + message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0'); + return; + } + } setSaving(true); + const llmManagedKeys = new Set([ + 'optimize_hold_credits', + 'llm_billing_enabled', + 'llm_hold_credits_default', + 'llm_hold_credits_generation_record_prompt', + 'llm_hold_credits_module_image_prompt', + 'llm_hold_credits_module_video_prompt', + 'llm_hold_credits_shot_video_analysis', + ]); for (const config of configs) { + if (llmManagedKeys.has(config.key)) continue; const newVal = values[config.key]; if (newVal !== undefined && String(newVal) !== config.value) { await updateSystemConfig(config.id, String(newVal ?? '')); } } - // AI创作预扣积分 - 不存在则创建 - const holdVal = values.optimize_hold_credits; - if (holdVal !== undefined && holdVal !== null && holdVal !== '') { - const existing = configs.find(c => c.key === 'optimize_hold_credits'); + const saveManagedConfig = async (key: string, value: unknown, description: string) => { + if (value === undefined || value === null || value === '') return; + const normalizedValue = String(value); + const existing = configs.find(c => c.key === key); if (existing) { - if (String(holdVal) !== existing.value) { - await updateSystemConfig(existing.id, String(holdVal)); - } + if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue); } else { - await createSystemConfig('optimize_hold_credits', String(holdVal), '提示词理解预扣积分数量(防止并发超卖)'); + await createSystemConfig(key, normalizedValue, description); } + }; + + const enabledConfig = [ + 'llm_billing_enabled', + values.llm_billing_enabled, + '是否启用 LLM 统一预扣与真实扣费结算', + ] as const; + const llmHoldConfigs = [ + ['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'], + ['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'], + ['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'], + ['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'], + ['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'], + ['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'], + ] as const; + + // 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。 + if (!llmBillingEnabled) { + await saveManagedConfig(...enabledConfig); } + for (const config of llmHoldConfigs) { + await saveManagedConfig(...config); + } + if (llmBillingEnabled) { + await saveManagedConfig(...enabledConfig); + } + await saveGlobalResourceCapacity({ enabled: !!values.resource_capacity_enabled, limitValue: String(values.resource_capacity_limit_value ?? '1.000'), @@ -193,7 +254,7 @@ const AdminSettings: React.FC = () => { 'SEO 设置': configs.filter(c => c.key.startsWith('seo_')), '用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')), '其他配置': configs.filter(c => c.key === 'operation_manual'), - 'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits'), + 'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')), }; const getFieldDescription = (config: SystemConfig): string => { @@ -209,7 +270,13 @@ const AdminSettings: React.FC = () => { user_login_credits: '用户每日登录赠送的积分数量', user_login_credits_enabled: '是否启用每日登录赠送积分功能', operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接', - optimize_hold_credits: 'AI创作时预扣积分数量,用于防止并发超卖。预扣后按实际消耗多退少补', + optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值', + llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算', + llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用', + llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分', + llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分', + llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分', + llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分', }; return descMap[config.key] || config.description || ''; }; @@ -339,7 +406,7 @@ const AdminSettings: React.FC = () => { ); } - if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits') { + if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) { return ; } return ; @@ -403,11 +470,23 @@ const AdminSettings: React.FC = () => { {/* AI创作预扣积分 - 固定显示 */} 提示词理解预扣积分数量} - extra="AI创作时预扣积分数量,用于防止并发超卖。预扣后按实际消耗多退少补" + label={兼容旧预扣积分数量} + extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值" > - + + {[ + ['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'], + ['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'], + ['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'], + ['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'], + ['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'], + ['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'], + ].map(([name, label, extra]) => ( + {label}} extra={extra}> + {name === 'llm_billing_enabled' ? : } + + ))} ), diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index d601ae8d..00a92680 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -913,6 +913,7 @@ export interface AdminCreditRecord { chargeKind?: string; chargeKindLabel?: string; chargeAction?: string; + chargeActionLabel?: string; creditSubject?: string; creditSubjectLabel?: string; mediaType?: string; @@ -956,6 +957,7 @@ export interface AdminCreditRecordQueryParams { creditSubject?: string; mediaType?: string; chargeKind?: string; + chargeAction?: string; sourceModule?: string; sourceStepCode?: string; billingScene?: string; diff --git a/video-gen-api/alembic/versions/6a3ea8d0b4c8_repair_generation_record_frozen_config.py b/video-gen-api/alembic/versions/6a3ea8d0b4c8_repair_generation_record_frozen_config.py new file mode 100644 index 00000000..f09fb08b --- /dev/null +++ b/video-gen-api/alembic/versions/6a3ea8d0b4c8_repair_generation_record_frozen_config.py @@ -0,0 +1,772 @@ +"""repair generation record frozen config + +Revision ID: 6a3ea8d0b4c8 +Revises: 7cf645f7c418 +Create Date: 2026-07-23 14:01:54.109369 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "6a3ea8d0b4c8" +down_revision: Union[str, None] = "7cf645f7c418" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +REPAIR_KEY = "repair_generation_record_frozen_config_20260723" +BACKUP_TABLE = "generation_records_repair_backup_20260723" + + +_TARGET_CONDITION = """ +gr.deleted_at IS NULL +AND p.deleted_at IS NULL +AND gr.status = 'prompt_optimized' +AND gr.gen_type IN ('video', 'image') +AND ( + gr.engine_id IS NULL + OR gr.engine_id = '' + OR gr.engine_snapshot_json IS NULL + OR gr.engine_snapshot_json = '' + OR gr.engine_snapshot_json IS NOT JSON + OR ( + gr.gen_type = 'video' + AND ( + gr.duration IS NULL + OR gr.aspect_ratio IS NULL + OR gr.aspect_ratio = '' + OR gr.resolution IS NULL + OR gr.resolution = '' + ) + ) + OR ( + gr.gen_type = 'image' + AND ( + gr.image_size IS NULL + OR gr.image_size = '' + OR gr.image_proportion IS NULL + OR gr.image_proportion = '' + OR gr.image_px IS NULL + OR gr.image_px = '' + ) + ) +) +""" + + +_CREATE_BACKUP_TABLE_SQL = f""" +CREATE TABLE IF NOT EXISTS {BACKUP_TABLE} ( + repair_key VARCHAR(96) NOT NULL, + record_id VARCHAR(32) NOT NULL, + gen_type VARCHAR(16), + old_engine_id VARCHAR(32), + old_engine_snapshot_json TEXT, + old_duration INTEGER, + old_aspect_ratio VARCHAR(8), + old_resolution VARCHAR(8), + old_provider_generation_resolution VARCHAR(16), + old_video_upscale_enabled_snapshot BOOLEAN, + old_video_upscale_snapshot_json TEXT, + old_image_size VARCHAR(8), + old_image_proportion VARCHAR(8), + old_image_px VARCHAR(10), + old_include_media_references BOOLEAN, + old_updated_at TIMESTAMP WITH TIME ZONE, + backed_up_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + PRIMARY KEY (repair_key, record_id) +) +""" + + +_INSERT_BACKUP_SQL = f""" +INSERT INTO {BACKUP_TABLE} ( + repair_key, + record_id, + gen_type, + old_engine_id, + old_engine_snapshot_json, + old_duration, + old_aspect_ratio, + old_resolution, + old_provider_generation_resolution, + old_video_upscale_enabled_snapshot, + old_video_upscale_snapshot_json, + old_image_size, + old_image_proportion, + old_image_px, + old_include_media_references, + old_updated_at +) +SELECT + :repair_key, + gr.id, + gr.gen_type, + gr.engine_id, + gr.engine_snapshot_json, + gr.duration, + gr.aspect_ratio, + gr.resolution, + gr.provider_generation_resolution, + gr.video_upscale_enabled_snapshot, + gr.video_upscale_snapshot_json, + gr.image_size, + gr.image_proportion, + gr.image_px, + gr.include_media_references, + gr.updated_at +FROM generation_records gr +JOIN projects p ON p.id = gr.project_id +WHERE {_TARGET_CONDITION} +ON CONFLICT (repair_key, record_id) DO NOTHING +""" + + +# PostgreSQL JSON helper snippets used inside SQL expressions. These avoid calling +# jsonb_array_elements_text/jsonb_object_keys on malformed JSON or the wrong JSON type. +_VIDEO_RATIOS_JSON = """ +CASE + WHEN ve.supported_ratios IS JSON THEN + CASE + WHEN jsonb_typeof(ve.supported_ratios::jsonb) = 'array' + THEN ve.supported_ratios::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb +END +""" + +_VIDEO_RESOLUTIONS_JSON = """ +CASE + WHEN ve.supported_resolutions IS JSON THEN + CASE + WHEN jsonb_typeof(ve.supported_resolutions::jsonb) = 'array' + THEN ve.supported_resolutions::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb +END +""" + +_VIDEO_DURATIONS_JSON = """ +CASE + WHEN ve.supported_durations IS JSON THEN + CASE + WHEN jsonb_typeof(ve.supported_durations::jsonb) = 'array' + THEN ve.supported_durations::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb +END +""" + +_IMAGE_SUPPORTED_MODELS_JSON = """ +CASE + WHEN px_resolved.supported_models IS JSON THEN + CASE + WHEN jsonb_typeof(px_resolved.supported_models::jsonb) = 'array' + THEN px_resolved.supported_models::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb +END +""" + +_IMAGE_SUPPORTED_SIZES_JSON_IE = """ +CASE + WHEN ie.supported_sizes IS JSON THEN + CASE + WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object' + THEN ie.supported_sizes::jsonb + ELSE '{}'::jsonb + END + ELSE '{}'::jsonb +END +""" + +_IMAGE_SUPPORTED_SIZES_JSON_ES = """ +CASE + WHEN ie.supported_sizes IS JSON THEN + CASE + WHEN jsonb_typeof(ie.supported_sizes::jsonb) = 'object' + THEN ie.supported_sizes::jsonb + ELSE '{}'::jsonb + END + ELSE '{}'::jsonb +END +""" + + +_REPAIR_VIDEO_SQL = f""" +WITH target_records AS ( + SELECT gr.* + FROM generation_records gr + JOIN projects p ON p.id = gr.project_id + WHERE {_TARGET_CONDITION} + AND gr.gen_type = 'video' +), +resolved AS ( + SELECT + tr.id AS record_id, + ve.id AS selected_engine_id, + ve.name AS engine_name, + ve.provider, + ve.api_base, + ve.api_key, + ve.model_name, + ve.generate_url, + ve.query_url, + ve.supported_ratios, + ve.supported_resolutions, + ve.supported_durations, + ve.max_duration, + ve.max_image_count, + ve.max_video_count, + ve.max_audio_count, + ve.supports_universal_reference, + ve.supports_first_last_frame, + ve.multi_generation_enabled, + ve.max_generation_count, + COALESCE( + CASE + WHEN tr.duration IS NOT NULL + AND tr.duration > 0 + AND ( + jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value) + WHERE d.value ~ '^[0-9]+$' + AND d.value::int = tr.duration + ) + ) + AND ( + ve.max_duration IS NULL + OR ve.max_duration <= 0 + OR tr.duration <= ve.max_duration + ) + THEN tr.duration + END, + ( + SELECT d.value::int + FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) WITH ORDINALITY d(value, ord) + WHERE d.value ~ '^[0-9]+$' + AND d.value::int > 0 + AND ( + ve.max_duration IS NULL + OR ve.max_duration <= 0 + OR d.value::int <= ve.max_duration + ) + ORDER BY d.ord + LIMIT 1 + ), + CASE + WHEN ve.max_duration IS NOT NULL AND ve.max_duration > 0 THEN LEAST(4, ve.max_duration) + ELSE 4 + END + ) AS final_duration, + COALESCE( + CASE + WHEN tr.aspect_ratio IS NOT NULL + AND tr.aspect_ratio <> '' + AND ( + jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value) + WHERE r.value = tr.aspect_ratio + ) + ) + THEN tr.aspect_ratio + END, + CASE + WHEN EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value) + WHERE r.value = '16:9' + ) + THEN '16:9' + END, + ( + SELECT r.value + FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) WITH ORDINALITY r(value, ord) + WHERE r.value <> '' + ORDER BY r.ord + LIMIT 1 + ), + '16:9' + ) AS final_aspect_ratio, + COALESCE( + CASE + WHEN tr.resolution IS NOT NULL + AND tr.resolution <> '' + AND ( + jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value) + WHERE r.value = tr.resolution + ) + ) + THEN tr.resolution + END, + CASE + WHEN EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value) + WHERE r.value = '480p' + ) + THEN '480p' + END, + ( + SELECT r.value + FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) WITH ORDINALITY r(value, ord) + WHERE r.value <> '' + ORDER BY r.ord + LIMIT 1 + ), + '480p' + ) AS final_resolution + FROM target_records tr + JOIN LATERAL ( + SELECT ve.* + FROM video_engines ve + WHERE ve.is_active IS TRUE + AND ve.deleted_at IS NULL + ORDER BY + CASE + WHEN ( + ( + tr.duration IS NULL + OR tr.duration <= 0 + OR jsonb_array_length({_VIDEO_DURATIONS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_DURATIONS_JSON}) d(value) + WHERE d.value ~ '^[0-9]+$' + AND d.value::int = tr.duration + ) + ) + AND ( + tr.aspect_ratio IS NULL + OR tr.aspect_ratio = '' + OR jsonb_array_length({_VIDEO_RATIOS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RATIOS_JSON}) r(value) + WHERE r.value = tr.aspect_ratio + ) + ) + AND ( + tr.resolution IS NULL + OR tr.resolution = '' + OR jsonb_array_length({_VIDEO_RESOLUTIONS_JSON}) = 0 + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text({_VIDEO_RESOLUTIONS_JSON}) r(value) + WHERE r.value = tr.resolution + ) + ) + ) + THEN 0 + ELSE 1 + END, + ve.priority DESC, + ve.created_at ASC, + ve.id ASC + LIMIT 1 + ) ve ON TRUE +) +UPDATE generation_records gr +SET + engine_id = resolved.selected_engine_id, + duration = resolved.final_duration, + aspect_ratio = resolved.final_aspect_ratio, + resolution = resolved.final_resolution, + provider_generation_resolution = resolved.final_resolution, + video_upscale_enabled_snapshot = FALSE, + video_upscale_snapshot_json = NULL, + engine_snapshot_json = jsonb_build_object( + 'engine_type', 'video', + 'id', resolved.selected_engine_id, + 'name', resolved.engine_name, + 'provider', resolved.provider, + 'api_base', resolved.api_base, + 'api_key_masked', CASE WHEN COALESCE(resolved.api_key, '') <> '' THEN '****' ELSE '' END, + 'model_name', resolved.model_name, + 'generate_url', resolved.generate_url, + 'query_url', resolved.query_url, + 'supported_ratios', CASE + WHEN resolved.supported_ratios IS JSON THEN + CASE + WHEN jsonb_typeof(resolved.supported_ratios::jsonb) = 'array' + THEN resolved.supported_ratios::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb + END, + 'supported_resolutions', CASE + WHEN resolved.supported_resolutions IS JSON THEN + CASE + WHEN jsonb_typeof(resolved.supported_resolutions::jsonb) = 'array' + THEN resolved.supported_resolutions::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb + END, + 'supported_durations', CASE + WHEN resolved.supported_durations IS JSON THEN + CASE + WHEN jsonb_typeof(resolved.supported_durations::jsonb) = 'array' + THEN resolved.supported_durations::jsonb + ELSE '[]'::jsonb + END + ELSE '[]'::jsonb + END, + 'max_duration', resolved.max_duration, + 'max_image_count', COALESCE(resolved.max_image_count, 0), + 'max_video_count', COALESCE(resolved.max_video_count, 0), + 'max_audio_count', COALESCE(resolved.max_audio_count, 0), + 'supports_universal_reference', COALESCE(resolved.supports_universal_reference, FALSE), + 'supports_first_last_frame', COALESCE(resolved.supports_first_last_frame, FALSE), + 'multi_generation_enabled', COALESCE(resolved.multi_generation_enabled, FALSE), + 'max_generation_count', LEAST(5, GREATEST(1, COALESCE(resolved.max_generation_count, 1))), + 'selected_ratio', resolved.final_aspect_ratio, + 'selected_resolution', resolved.final_resolution, + 'selected_duration', resolved.final_duration + )::text, + include_media_references = COALESCE(gr.include_media_references, FALSE), + updated_at = NOW() +FROM resolved +WHERE gr.id = resolved.record_id +""" + + +_REPAIR_IMAGE_SQL = f""" +WITH target_records AS ( + SELECT gr.* + FROM generation_records gr + JOIN projects p ON p.id = gr.project_id + WHERE {_TARGET_CONDITION} + AND gr.gen_type = 'image' +), +engine_selected AS ( + SELECT + tr.*, + ie.id AS selected_engine_id, + ie.name AS engine_name, + ie.provider, + ie.api_base, + ie.api_key, + ie.model_name, + ie.generate_url, + ie.supported_models, + ie.supported_sizes, + ie.default_size, + ie.multi_generation_enabled, + ie.max_generation_count, + ie.multi_image_max_images, + ie.max_reference_image_count, + ie.output_format, + CASE + WHEN ie.supported_sizes IS JSON AND jsonb_typeof(ie.supported_sizes::jsonb) = 'object' + THEN ie.supported_sizes::jsonb + ELSE '{{}}'::jsonb + END AS sizes_json + FROM target_records tr + JOIN LATERAL ( + SELECT ie.* + FROM image_engines ie + WHERE ie.is_active IS TRUE + AND ie.deleted_at IS NULL + ORDER BY + CASE + WHEN ( + ie.supported_sizes IS NULL + OR ie.supported_sizes = '' + OR ie.supported_sizes IS NOT JSON + OR jsonb_typeof(ie.supported_sizes::jsonb) <> 'object' + OR ie.supported_sizes::jsonb = '{{}}'::jsonb + OR ( + ( + tr.image_size IS NULL + OR tr.image_size = '' + OR ie.supported_sizes::jsonb ? tr.image_size + ) + AND ( + tr.image_size IS NULL + OR tr.image_size = '' + OR tr.image_proportion IS NULL + OR tr.image_proportion = '' + OR ( + jsonb_typeof(ie.supported_sizes::jsonb -> tr.image_size) = 'object' + AND (ie.supported_sizes::jsonb -> tr.image_size) ? tr.image_proportion + ) + ) + ) + ) + THEN 0 + ELSE 1 + END, + ie.priority DESC, + ie.created_at ASC, + ie.id ASC + LIMIT 1 + ) ie ON TRUE +), +size_selected AS ( + SELECT + es.*, + COALESCE( + CASE + WHEN es.image_size IS NOT NULL + AND es.image_size <> '' + AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.image_size) + THEN es.image_size + END, + CASE + WHEN es.default_size IS NOT NULL + AND es.default_size <> '' + AND (es.sizes_json = '{{}}'::jsonb OR es.sizes_json ? es.default_size) + THEN es.default_size + END, + CASE WHEN es.sizes_json ? '2K' THEN '2K' END, + ( + SELECT key + FROM jsonb_object_keys(es.sizes_json) AS key + ORDER BY key + LIMIT 1 + ), + '2K' + ) AS final_image_size + FROM engine_selected es +), +ratio_selected AS ( + SELECT + ss.*, + CASE + WHEN jsonb_typeof(ss.sizes_json -> ss.final_image_size) = 'object' + THEN ss.sizes_json -> ss.final_image_size + ELSE '{{}}'::jsonb + END AS ratio_json + FROM size_selected ss +), +final_resolved AS ( + SELECT + rs.*, + COALESCE( + CASE + WHEN rs.image_proportion IS NOT NULL + AND rs.image_proportion <> '' + AND (rs.ratio_json = '{{}}'::jsonb OR rs.ratio_json ? rs.image_proportion) + THEN rs.image_proportion + END, + CASE WHEN rs.ratio_json ? '1:1' THEN '1:1' END, + ( + SELECT key + FROM jsonb_object_keys(rs.ratio_json) AS key + ORDER BY key + LIMIT 1 + ), + '1:1' + ) AS final_image_proportion + FROM ratio_selected rs +), +px_resolved AS ( + SELECT + fr.*, + regexp_replace( + lower( + replace( + COALESCE( + NULLIF(fr.ratio_json ->> fr.final_image_proportion, ''), + NULLIF(fr.image_px, ''), + '2048x2048' + ), + '×', + 'x' + ) + ), + 'x+', + 'x', + 'g' + ) AS final_image_px + FROM final_resolved fr +) +UPDATE generation_records gr +SET + engine_id = px_resolved.selected_engine_id, + image_size = px_resolved.final_image_size, + image_proportion = px_resolved.final_image_proportion, + image_px = LEFT(px_resolved.final_image_px, 10), + provider_generation_resolution = NULL, + video_upscale_enabled_snapshot = FALSE, + video_upscale_snapshot_json = NULL, + engine_snapshot_json = jsonb_build_object( + 'engine_type', 'image', + 'id', px_resolved.selected_engine_id, + 'name', px_resolved.engine_name, + 'provider', px_resolved.provider, + 'api_base', px_resolved.api_base, + 'api_key_masked', CASE WHEN COALESCE(px_resolved.api_key, '') <> '' THEN '****' ELSE '' END, + 'model_name', px_resolved.model_name, + 'generate_url', px_resolved.generate_url, + 'supported_models', {_IMAGE_SUPPORTED_MODELS_JSON}, + 'default_size', px_resolved.default_size, + 'multi_generation_enabled', COALESCE(px_resolved.multi_generation_enabled, FALSE), + 'max_generation_count', LEAST(5, GREATEST(1, COALESCE(px_resolved.max_generation_count, 1))), + 'multi_image_max_images', COALESCE(px_resolved.multi_image_max_images, 15), + 'max_reference_image_count', COALESCE(px_resolved.max_reference_image_count, 0), + 'output_format', lower(trim(COALESCE(px_resolved.output_format, ''))), + 'selected_size', px_resolved.final_image_size, + 'selected_proportion', px_resolved.final_image_proportion, + 'selected_px', LEFT(px_resolved.final_image_px, 10) + )::text, + include_media_references = COALESCE(gr.include_media_references, FALSE), + updated_at = NOW() +FROM px_resolved +WHERE gr.id = px_resolved.id +""" + + +_DOWNGRADE_SQL = f""" +UPDATE generation_records gr +SET + engine_id = b.old_engine_id, + engine_snapshot_json = b.old_engine_snapshot_json, + duration = b.old_duration, + aspect_ratio = b.old_aspect_ratio, + resolution = b.old_resolution, + provider_generation_resolution = b.old_provider_generation_resolution, + video_upscale_enabled_snapshot = COALESCE(b.old_video_upscale_enabled_snapshot, FALSE), + video_upscale_snapshot_json = b.old_video_upscale_snapshot_json, + image_size = b.old_image_size, + image_proportion = b.old_image_proportion, + image_px = b.old_image_px, + include_media_references = COALESCE(b.old_include_media_references, FALSE), + updated_at = b.old_updated_at +FROM {BACKUP_TABLE} b +WHERE b.repair_key = :repair_key + AND b.record_id = gr.id + AND gr.status = 'prompt_optimized' + AND gr.deleted_at IS NULL +""" + + +_DROP_BACKUP_TABLE_SQL = f"DROP TABLE IF EXISTS {BACKUP_TABLE}" + + +def _scalar_int(sql: str, **params: object) -> int: + bind = op.get_bind() + value = bind.execute(sa.text(sql), params).scalar() + return int(value or 0) + + +def _ensure_required_engines() -> None: + video_targets = _scalar_int( + f""" + SELECT COUNT(*) + FROM generation_records gr + JOIN projects p ON p.id = gr.project_id + WHERE {_TARGET_CONDITION} + AND gr.gen_type = 'video' + """ + ) + image_targets = _scalar_int( + f""" + SELECT COUNT(*) + FROM generation_records gr + JOIN projects p ON p.id = gr.project_id + WHERE {_TARGET_CONDITION} + AND gr.gen_type = 'image' + """ + ) + video_engines = _scalar_int( + """ + SELECT COUNT(*) + FROM video_engines + WHERE is_active IS TRUE + AND deleted_at IS NULL + """ + ) + image_engines = _scalar_int( + """ + SELECT COUNT(*) + FROM image_engines + WHERE is_active IS TRUE + AND deleted_at IS NULL + """ + ) + + if video_targets > 0 and video_engines <= 0: + raise RuntimeError("存在待修复的视频生成记录,但没有可用的视频引擎") + if image_targets > 0 and image_engines <= 0: + raise RuntimeError("存在待修复的图片生成记录,但没有可用的图片引擎") + + +def _ensure_postgresql() -> None: + bind = op.get_bind() + dialect_name = getattr(bind.dialect, "name", "") + if dialect_name != "postgresql": + raise RuntimeError("本迁移只支持 PostgreSQL,当前数据库类型不支持此数据修复") + + +def _execute(sql: str, **params: object) -> None: + bind = op.get_bind() + bind.execute(sa.text(sql), params) + + +def _backup_table_exists() -> bool: + return ( + _scalar_int( + """ + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = current_schema() + AND table_name = :table_name + """, + table_name=BACKUP_TABLE, + ) + > 0 + ) + + +def upgrade() -> None: + """Repair historical prompt_optimized GenerationRecord frozen config. + + This migration only fills missing frozen generation configuration for old + records. It does not change status, charge credits, create tasks, enqueue + Celery jobs, or call application services. + """ + + _ensure_postgresql() + _execute(_CREATE_BACKUP_TABLE_SQL) + _ensure_required_engines() + + # Keep the original values for a guarded downgrade. The ON CONFLICT clause + # makes this migration safe to re-run inside a partially repaired database. + _execute(_INSERT_BACKUP_SQL, repair_key=REPAIR_KEY) + + # Repair by generation type. The update SQL only targets records that are + # still prompt_optimized and still incomplete, so already repaired records + # are skipped. + _execute(_REPAIR_VIDEO_SQL) + _execute(_REPAIR_IMAGE_SQL) + + +def downgrade() -> None: + """Restore backed-up values for records that are still not generated. + + Records that moved past prompt_optimized are intentionally not restored; + reverting those after users have generated media would corrupt production + state. + """ + + _ensure_postgresql() + if not _backup_table_exists(): + return + + _execute(_DOWNGRADE_SQL, repair_key=REPAIR_KEY) + _execute(_DROP_BACKUP_TABLE_SQL) diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 2d18f002..c2f37871 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta import json from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, get_admin_user @@ -50,6 +50,8 @@ from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut from app.services.credits import add_credits, deduct_credits from app.services.credit_record_meta_service import build_admin_adjust_meta from app.services.admin_credit_record_service import list_admin_credit_records +from app.services.system_config_cache import invalidate_system_config_cache +from app.services.llm_billing.config import validate_llm_system_config_value from app.services.notification import create_notification from app.services.auth import hash_password, verify_password from app.services.operation_log import log_operation @@ -502,6 +504,7 @@ async def list_credit_records( credit_subject: str | None = Query(None), media_type: str | None = Query(None), charge_kind: str | None = Query(None), + charge_action: str | None = Query(None), source_module: str | None = Query(None), source_step_code: str | None = Query(None), billing_scene: str | None = Query(None), @@ -524,6 +527,7 @@ async def list_credit_records( credit_subject=credit_subject, media_type=media_type, charge_kind=charge_kind, + charge_action=charge_action, source_module=source_module, source_step_code=source_step_code, billing_scene=billing_scene, @@ -1625,6 +1629,10 @@ async def create_system_config( db: AsyncSession = Depends(get_db), ): from app.utils.id_gen import generate_id + try: + await validate_llm_system_config_value(db, key=req.key, value=str(req.value)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc config = SystemConfig( id=generate_id(), key=req.key, @@ -1643,6 +1651,8 @@ async def create_system_config( detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False), ) await db.commit() + await invalidate_system_config_cache([req.key]) + await db.refresh(config) return config @@ -1657,6 +1667,10 @@ async def update_system_config( config = result.scalar_one_or_none() if not config: raise HTTPException(status_code=404, detail="配置不存在") + try: + await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc config.value = str(req.value) await db.flush() await log_operation( @@ -1675,7 +1689,10 @@ async def update_system_config( ensure_ascii=False, ), ) + updated_key = str(config.key) await db.commit() + await invalidate_system_config_cache([updated_key]) + await db.refresh(config) return config @@ -1785,9 +1802,16 @@ async def get_stats( ) )).scalar() or 0 + # 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。 + real_credit_charge_filter = or_( + CreditRecord.charge_action.is_(None), + CreditRecord.charge_action == "charge", + ) + credits_consumed = (await db.execute( select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= date_start, CreditRecord.created_at <= date_end, ) @@ -1858,6 +1882,7 @@ async def get_stats( last_period_credits_consumed = (await db.execute( select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= last_period_start, CreditRecord.created_at <= last_period_end, ) @@ -1877,6 +1902,7 @@ async def get_stats( ) .where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= _chart_start_dt, CreditRecord.created_at <= _chart_end_dt, ) @@ -1903,6 +1929,7 @@ async def get_stats( ) .where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= date_start, CreditRecord.created_at <= date_end, ) @@ -1929,6 +1956,7 @@ async def get_stats( ) .where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= date_start, CreditRecord.created_at <= date_end, ) @@ -1949,6 +1977,7 @@ async def get_stats( ) .where( CreditRecord.type == "consume", + real_credit_charge_filter, CreditRecord.created_at >= date_start, CreditRecord.created_at <= date_end, ) @@ -1972,6 +2001,7 @@ async def get_stats( .where( ChatGenerationTask.gen_type == "video", CreditRecord.type == "consume", + real_credit_charge_filter, ChatGenerationTask.created_at >= date_start, ChatGenerationTask.created_at <= date_end, ChatGenerationTask.deleted_at.is_(None), diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index 39fdacc2..5fc3a4fe 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -15,7 +15,6 @@ from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.project import Project from app.models.generation_record import GenerationRecord -from app.models.system_config import SystemConfig from app.schemas.generation import ( OptimizeParams, GenerationRecordOut, @@ -27,7 +26,6 @@ from app.services.generation.pipeline.db_lock_service import ( DatabaseRowLockBusy, execute_with_lock_timeout, ) -from app.services.credits import deduct_credits, add_credits, calc_text_credits from app.services.llm import optimize_prompt from app.services.video_url import validate_and_get_record_id, get_video_stream_url from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls @@ -45,14 +43,27 @@ from app.enums.generation_status import ( GenerationType, ) from app.enums.common import LogEventStatusEnum +from app.enums.credit_record import ( + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordSourceModule, +) +from app.enums.llm_billing import LlmBillingConfigKey +from app.services.llm_billing import ( + LlmBillingContext, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, + start_hold, +) from app.enums.generation_record import ( GenerationRecordConfigSourceEnum, GenerationRecordEventTypeEnum, ) from app.services.generation.billing_service import ( - CHARGE_TEXT_PROMPT, OWNER_GENERATION_RECORD, - build_credit_biz_key, charge_generation_media_for_record, get_next_credit_attempt_no, ) @@ -76,7 +87,6 @@ from app.services.generation.media_reference_service import ( calculate_media_reference_usage, validate_media_reference_usage_for_engine, ) -from app.services.credit_record_meta_service import build_generation_record_prompt_meta from app.enums.audio_reference import ( AUDIO_ALLOWED_EXTENSIONS, AUDIO_ALLOWED_MIME_TYPES, @@ -506,29 +516,26 @@ async def optimize( ) - hold_credits = 5 - hold_result = await db.execute( - select(SystemConfig).where(SystemConfig.key == "optimize_hold_credits").limit(1) - ) - hold_row = hold_result.scalar_one_or_none() - if hold_row and hold_row.value: - try: - hold_credits = max(0, int(hold_row.value)) - except (ValueError, TypeError): - hold_credits = 5 - - hold_scope = req.idempotency_key or generate_id() - hold_biz_key = f"optimize_hold:{hold_scope}" - hold_refund_biz_key = f"optimize_hold_refund:{hold_scope}" - await deduct_credits( - db, - user_id_snapshot, - hold_credits, - "AI创作预扣积分", - biz_key=hold_biz_key, + record_id_value = generate_id() + prompt_attempt_no = 1 + llm_billing_context = LlmBillingContext( + user_id=user_id_snapshot, + owner_type=OWNER_GENERATION_RECORD, + owner_id=record_id_value, + attempt_no=prompt_attempt_no, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value, + source_module=CreditRecordSourceModule.GENERATION_RECORD.value, + related_id=record_id_value, + hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, + description_prefix="AI创作提示词优化", + trace_id=f"generation-optimize:{record_id_value}", + request_id=req.idempotency_key, ) + await start_hold(db, llm_billing_context) await db.commit() + log_provider_start(llm_billing_context, detail={"gen_type": req.gen_type.value}) try: optimized, token_usage = await optimize_prompt( db, @@ -544,55 +551,63 @@ async def optimize( log_module="generation_record", log_step="prompt_optimize", log_project_id=req.project_id, + log_owner_type=OWNER_GENERATION_RECORD, + log_owner_id=record_id_value, + generation_attempt_no=prompt_attempt_no, ) + log_provider_success(llm_billing_context, usage=token_usage) except Exception as exc: from app.services.error_codes import extract_error_message await db.rollback() - await add_credits( - db, - user_id_snapshot, - hold_credits, - "AI创作预扣积分退还", - record_type="refund", - biz_key=hold_refund_biz_key, - refund_for_biz_key=hold_biz_key, - ) + log_provider_failure(llm_billing_context, error=str(exc)) + await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() raise HTTPException( status_code=502, detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}", ) from exc - try: - text_credits = await calc_text_credits( - db, - int(token_usage.get("input_tokens", 0) or 0), - int(token_usage.get("output_tokens", 0) or 0), - ) - - record = GenerationRecord( - id=generate_id(), - user_id=user_id_snapshot, - project_id=req.project_id, - original_prompt=req.prompt, - optimized_prompt=optimized, - gen_type=req.gen_type.value, - duration=req.duration if req.gen_type == GenerationType.video else None, - aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None, - resolution=req.resolution if req.gen_type == GenerationType.video else None, - image_size=req.image_size if req.gen_type == GenerationType.image else None, - image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, - image_px=req.image_px if req.gen_type == GenerationType.image else None, - status="prompt_optimized", - pipeline_stage=None, - credits_cost=0, - text_credits_cost=round(text_credits, 2), - text_tokens_used=int(token_usage.get("total_tokens", 0) or 0), - media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None, - include_media_references=bool(req.include_media_references), - idempotency_key=req.idempotency_key, + async def _persist_optimized_result() -> str: + existing_result = await db.execute( + select(GenerationRecord) + .where(GenerationRecord.id == record_id_value) + .with_for_update() + .limit(1) ) + record = existing_result.scalar_one_or_none() + if record is None: + record = GenerationRecord( + id=record_id_value, + user_id=user_id_snapshot, + project_id=req.project_id, + original_prompt=req.prompt, + optimized_prompt=optimized, + gen_type=req.gen_type.value, + duration=req.duration if req.gen_type == GenerationType.video else None, + aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None, + resolution=req.resolution if req.gen_type == GenerationType.video else None, + image_size=req.image_size if req.gen_type == GenerationType.image else None, + image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, + image_px=req.image_px if req.gen_type == GenerationType.image else None, + status="prompt_optimized", + pipeline_stage=None, + credits_cost=0, + text_credits_cost=0, + text_tokens_used=int(token_usage.get("total_tokens", 0) or 0), + media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None, + include_media_references=bool(req.include_media_references), + idempotency_key=req.idempotency_key, + ) + db.add(record) + else: + # commit 结果不确定或本地持久化重试时,复用同一主键和同一账务 attempt。 + record.optimized_prompt = optimized + record.status = "prompt_optimized" + record.pipeline_stage = None + record.error_message = None + record.text_credits_cost = 0 + record.text_tokens_used = int(token_usage.get("total_tokens", 0) or 0) if req.gen_type == GenerationType.video: from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot @@ -619,61 +634,41 @@ async def optimize( engine=engine_snapshot_source, source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE, ) - db.add(record) await db.flush() - - prompt_attempt_no = 1 - prompt_biz_key = build_credit_biz_key( - owner_type=OWNER_GENERATION_RECORD, - owner_id=record.id, - attempt_no=prompt_attempt_no, - charge_kind=CHARGE_TEXT_PROMPT, - action="charge", - ) - prompt_meta = await build_generation_record_prompt_meta( + billing = await settle_success( db, - record_id=record.id, - attempt_no=prompt_attempt_no, - charge_kind=CHARGE_TEXT_PROMPT, + llm_billing_context, usage=token_usage, + description=f"提示词优化 - {project_name_snapshot}", ) - # Release the hold and charge the exact prompt usage in one transaction. - await add_credits( - db, - user_id_snapshot, - hold_credits, - f"AI创作预扣积分退还 - {project_name_snapshot}", - related_id=record.id, - record_type="refund", - biz_key=hold_refund_biz_key, - refund_for_biz_key=hold_biz_key, - ) - await deduct_credits( - db, - user_id_snapshot, - text_credits, - f"提示词优化 - {project_name_snapshot}", - related_id=record.id, - biz_key=prompt_biz_key, - record_meta=prompt_meta, + charge_item = next( + (item for item in billing.items if item.biz_key == llm_billing_context.charge_biz_key), + None, ) + if charge_item: + record.text_credits_cost = round(charge_item.amount, 2) record_id_snapshot = str(record.id) await db.commit() - except Exception: + return record_id_snapshot + + try: + record_id_snapshot = await _persist_optimized_result() + except Exception as first_exc: await db.rollback() - # Any local pricing/snapshot/persistence failure after the provider call - # must release the committed hold. The refund key is idempotent. - await add_credits( - db, - user_id_snapshot, - hold_credits, - "AI创作预扣积分退还", - record_type="refund", - biz_key=hold_refund_biz_key, - refund_for_biz_key=hold_biz_key, + logger.exception( + "prompt optimize local persistence/settlement failed after provider success; retry once: record_id=%s", + record_id_value, ) - await db.commit() - raise + try: + record_id_snapshot = await _persist_optimized_result() + except Exception: + await db.rollback() + logger.exception( + "prompt optimize idempotent persistence retry failed; active HOLD retained for repair: record_id=%s", + record_id_value, + ) + raise first_exc + refreshed = await db.execute( select(GenerationRecord, Project.name) diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py index a7d40580..3369ea51 100644 --- a/video-gen-api/app/api/v1/hot_opening_replicate.py +++ b/video-gen-api/app/api/v1/hot_opening_replicate.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from types import SimpleNamespace +from typing import Any from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile from sqlalchemy import inspect as sa_inspect @@ -11,6 +12,12 @@ from app.dependencies import get_current_user, get_db from app.models.user import User from app.enums.common import ModuleProjectStatusEnum, ModuleEventTypeEnum from app.enums.generation_task import GenerationOwnerType +from app.enums.credit_record import ( + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, +) +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum from app.schemas.hot_opening_replicate import ( HotOpeningActionOut, @@ -43,10 +50,20 @@ from app.services.hot_opening_replicate_service import ( update_hot_opening_video_prompt_schema, ) from app.services.module_generation_log_service import log_module_error, log_module_event_file +from app.services.llm_billing import ( + LlmBillingContext, + log_celery_dispatch_compensated, + log_celery_dispatch_failure, + log_celery_dispatch_start, + log_celery_dispatch_success, +) from app.services.module_async_recovery_service import ( + OBJECT_MODULE_STEP, TASK_HOT_IMAGE_PROMPT, TASK_HOT_VIDEO_PROMPT, + has_live_object_lock, register_module_step_task, + remove_active_task, ) from app.tasks.celery_app import celery_app from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum @@ -138,6 +155,47 @@ def _log_api_exception_from_locals(exc: BaseException, local_values: dict, messa exc=exc, ) +def _prompt_dispatch_billing_context( + *, + user_id: str, + project_id: str, + step_id: str, + step_code: str, + attempt_no: int, + celery_task_id: str, +) -> LlmBillingContext: + is_image = step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + return LlmBillingContext( + user_id=user_id, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value + if is_image + else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=MODULE, + source_project_id=project_id, + source_step_id=step_id, + source_step_code=step_code, + related_id=step_id, + hold_config_key=( + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value + if is_image + else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value + ), + description_prefix=( + "爆款开头复刻图片AI提词优化" + if is_image + else "爆款开头复刻视频提词优化" + ), + trace_id=f"hot-opening-prompt:{step_id}:attempt:{attempt_no}", + celery_task_id=celery_task_id, + ) + + async def _reload_project_detail( db: AsyncSession, current_user: User, @@ -161,10 +219,26 @@ async def _mark_dispatch_failed_and_raise( project_id: str, step_id: str | None, message: str, + billing_context: LlmBillingContext | None = None, ) -> None: - """Celery 投递失败后,数据库事务已提交,单独标记步骤失败,避免一直 processing。""" + """Celery 投递失败后补偿步骤和冻结积分,避免一直 processing。""" + if billing_context is not None: + log_celery_dispatch_failure(billing_context, error=message) + compensated = False if step_id: try: + if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id): + log_module_error( + module=MODULE, + event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿", + detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message}, + error=message, + ) + raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交") await mark_hot_opening_step_dispatch_failed( db, current_user=_user_context(current_user), @@ -173,6 +247,23 @@ async def _mark_dispatch_failed_and_raise( error_message=message, ) await db.commit() + compensated = True + if billing_context is not None: + log_celery_dispatch_compensated(billing_context, error=message) + try: + await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id) + except Exception as cleanup_exc: + _log_api_error( + event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery 投递补偿完成,但清理 active registry 失败", + detail={"dispatch_error": message}, + exc=cleanup_exc, + ) + except HTTPException: + raise except Exception as exc: await db.rollback() _log_api_error( @@ -191,12 +282,89 @@ async def _mark_dispatch_failed_and_raise( step_id=step_id, user_id=_safe_user_id(current_user), message=message, - detail={"reason": "celery_dispatch_failed"}, + detail={"reason": "celery_dispatch_failed", "compensated": compensated}, error=message, ) raise HTTPException(status_code=503, detail=message) +async def _dispatch_prompt_task( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + step_code: str, + task_name: str, + celery_task: Any, + celery_task_id: str, + billing_context: LlmBillingContext, + error_prefix: str, +) -> None: + """Redis 注册与 Celery 直投任一成功即视为可恢复投递。""" + registry_error: Exception | None = None + try: + await register_module_step_task( + module=MODULE, + project_id=project_id, + step_id=step_id, + step_code=step_code, + task_name=task_name, + ) + except Exception as exc: + registry_error = exc + log_module_error( + module=MODULE, + event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投", + detail={"channel": "active_registry"}, + exc=exc, + ) + + celery_error: Exception | None = None + try: + celery_task.apply_async( + args=[project_id, step_id], + queue="gen_chatapi_create", + countdown=0, + task_id=celery_task_id, + ) + except Exception as exc: + celery_error = exc + + if celery_error is None: + log_celery_dispatch_success(billing_context) + return + if registry_error is None: + log_celery_dispatch_failure( + billing_context, + error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}", + ) + log_module_event_file( + module=MODULE, + event_type=HotOpeningLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="Celery 直投失败,任务将由 active registry 恢复投递", + detail={"recoverable": True, "celery_task_id": celery_task_id}, + error=str(celery_error), + ) + return + + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id, + step_id=step_id, + message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})", + billing_context=billing_context, + ) + + @router.get( "/spec", response_model=HotOpeningSpecOut, @@ -514,6 +682,17 @@ async def generate_image_prompt( project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id) project_id_value = str(project.id) step_id_value = str(step.id) + user_id_value = str(project.user_id) + attempt_no_value = int(step.version or 1) + celery_task_id = f"hot-opening:image-prompt:{step_id_value}" + billing_context = _prompt_dispatch_billing_context( + user_id=user_id_value, + project_id=project_id_value, + step_id=step_id_value, + step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + attempt_no=attempt_no_value, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException: await db.rollback() @@ -525,23 +704,19 @@ async def generate_image_prompt( from app.tasks.hot_opening_replicate_tasks import start_image_prompt_optimize - await register_module_step_task( - module=MODULE, + log_celery_dispatch_start(billing_context) + await _dispatch_prompt_task( + db, + current_user=current_user, project_id=project_id_value, step_id=step_id_value, step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, task_name=TASK_HOT_IMAGE_PROMPT, + celery_task=start_image_prompt_optimize, + celery_task_id=celery_task_id, + billing_context=billing_context, + error_prefix="图片提词任务投递失败", ) - try: - start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) - except Exception as exc: - await _mark_dispatch_failed_and_raise( - db, - current_user=current_user, - project_id=project_id_value, - step_id=step_id_value, - message=f"图片提词任务投递失败: {exc}", - ) return HotOpeningActionOut( message="图片 AI 提词任务已提交", @@ -657,6 +832,17 @@ async def generate_video_prompt( project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req) project_id_value = str(project.id) step_id_value = str(step.id) + user_id_value = str(project.user_id) + attempt_no_value = int(step.version or 1) + celery_task_id = f"hot-opening:video-prompt:{step_id_value}" + billing_context = _prompt_dispatch_billing_context( + user_id=user_id_value, + project_id=project_id_value, + step_id=step_id_value, + step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + attempt_no=attempt_no_value, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException: await db.rollback() @@ -668,23 +854,19 @@ async def generate_video_prompt( from app.tasks.hot_opening_replicate_tasks import start_video_prompt_optimize - await register_module_step_task( - module=MODULE, + log_celery_dispatch_start(billing_context) + await _dispatch_prompt_task( + db, + current_user=current_user, project_id=project_id_value, step_id=step_id_value, step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, task_name=TASK_HOT_VIDEO_PROMPT, + celery_task=start_video_prompt_optimize, + celery_task_id=celery_task_id, + billing_context=billing_context, + error_prefix="视频提词任务投递失败", ) - try: - start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue="gen_chatapi_create", countdown=0) - except Exception as exc: - await _mark_dispatch_failed_and_raise( - db, - current_user=current_user, - project_id=project_id_value, - step_id=step_id_value, - message=f"视频提词任务投递失败: {exc}", - ) return HotOpeningActionOut( message="视频 AI 提词任务已提交", diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py index a45443ad..604d43f7 100644 --- a/video-gen-api/app/api/v1/shot_replicate.py +++ b/video-gen-api/app/api/v1/shot_replicate.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from types import SimpleNamespace +from typing import Any from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile from sqlalchemy import inspect as sa_inspect @@ -13,6 +14,12 @@ from app.dependencies import get_current_user, get_db from app.models.user import User from app.enums.common import ModuleEventTypeEnum from app.enums.generation_task import GenerationOwnerType +from app.enums.credit_record import ( + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, +) +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.shot_replicate import ( ModuleCodeEnum, ShotAnalysisStatusEnum, @@ -65,6 +72,7 @@ from app.services.shot_replicate_flow_service import ( update_shot_replicate_video_prompt_schema, ) from app.services.shot_replicate_taskset_service import ( + build_task_set_analysis_billing_context, create_custom_segment, create_segments_by_ai, create_task_set, @@ -72,6 +80,9 @@ from app.services.shot_replicate_taskset_service import ( delete_task_set, list_segments, list_task_sets, + mark_custom_segment_split_dispatch_failed, + mark_segment_analysis_dispatch_failed, + mark_task_set_analysis_dispatch_failed, prepare_reanalyze_segment, prepare_reanalyze_task_set, prepare_retry_split_segment, @@ -79,10 +90,20 @@ from app.services.shot_replicate_taskset_service import ( task_set_detail, ) from app.services.module_generation_log_service import log_module_error, log_module_event_file +from app.services.llm_billing import ( + LlmBillingContext, + log_celery_dispatch_compensated, + log_celery_dispatch_failure, + log_celery_dispatch_start, + log_celery_dispatch_success, +) from app.services.module_async_recovery_service import ( + OBJECT_MODULE_STEP, TASK_SHOT_IMAGE_PROMPT, TASK_SHOT_VIDEO_PROMPT, + has_live_object_lock, register_module_step_task, + remove_active_task, ) from app.tasks.celery_app import celery_app from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum @@ -187,6 +208,83 @@ def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str ) raise HTTPException(status_code=503, detail=message) +def _prompt_dispatch_billing_context( + *, + user_id: str, + project_id: str, + step_id: str, + step_code: str, + attempt_no: int, + celery_task_id: str, +) -> LlmBillingContext: + is_image = step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + return LlmBillingContext( + user_id=user_id, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value + if is_image + else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=MODULE, + source_project_id=project_id, + source_step_id=step_id, + source_step_code=step_code, + related_id=step_id, + hold_config_key=( + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value + if is_image + else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value + ), + description_prefix=( + "拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化" + ), + trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}", + celery_task_id=celery_task_id, + ) + + +def _analysis_dispatch_billing_context( + *, + user_id: str, + owner_id: str, + attempt_no: int, + task_set_id: str, + is_segment: bool, + celery_task_id: str, +) -> LlmBillingContext: + return LlmBillingContext( + user_id=user_id, + owner_type=( + CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value + if is_segment + else CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value + ), + owner_id=owner_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + billing_scene=( + CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value + if is_segment + else CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value + ), + source_module=MODULE, + source_project_id=task_set_id, + source_step_id=owner_id, + source_step_code=ShotReplicateStepCodeEnum.VIDEO_ANALYSIS.value, + related_id=owner_id, + hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + description_prefix=( + "拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析" + ), + trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}", + celery_task_id=celery_task_id, + ) + + async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut: project = await _get_project_for_user( db, @@ -205,9 +303,25 @@ async def _mark_dispatch_failed_and_raise( project_id: str, step_id: str | None, message: str, + billing_context: LlmBillingContext | None = None, ) -> None: + if billing_context is not None: + log_celery_dispatch_failure(billing_context, error=message) + compensated = False if step_id: try: + if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id): + log_module_error( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="Celery 投递返回异常,但 worker 已领取任务,跳过失败补偿", + detail={"reason": "uncertain_dispatch_worker_started", "dispatch_error": message}, + error=message, + ) + raise HTTPException(status_code=503, detail=f"{message};任务可能已被 worker 接收,请勿重复提交") await mark_shot_replicate_step_dispatch_failed( db, current_user=_user_context(current_user), @@ -216,6 +330,23 @@ async def _mark_dispatch_failed_and_raise( error_message=message, ) await db.commit() + compensated = True + if billing_context is not None: + log_celery_dispatch_compensated(billing_context, error=message) + try: + await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=step_id) + except Exception as cleanup_exc: + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=project_id, + step_id=step_id, + message="Celery 投递补偿完成,但清理 active registry 失败", + detail={"dispatch_error": message}, + exc=cleanup_exc, + ) + except HTTPException: + raise except Exception as exc: await db.rollback() _log_api_error( @@ -234,12 +365,89 @@ async def _mark_dispatch_failed_and_raise( step_id=step_id, user_id=_safe_user_id(current_user), message=message, - detail={"reason": "celery_dispatch_failed"}, + detail={"reason": "celery_dispatch_failed", "compensated": compensated}, error=message, ) raise HTTPException(status_code=503, detail=message) +async def _dispatch_prompt_task( + db: AsyncSession, + *, + current_user: User, + project_id: str, + step_id: str, + step_code: str, + task_name: str, + celery_task: Any, + celery_task_id: str, + billing_context: LlmBillingContext, + error_prefix: str, +) -> None: + """Redis 注册与 Celery 直投任一成功即视为可恢复投递。""" + registry_error: Exception | None = None + try: + await register_module_step_task( + module=MODULE, + project_id=project_id, + step_id=step_id, + step_code=step_code, + task_name=task_name, + ) + except Exception as exc: + registry_error = exc + log_module_error( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="提词任务 Redis 活跃注册失败,将继续尝试 Celery 直投", + detail={"channel": "active_registry"}, + exc=exc, + ) + + celery_error: Exception | None = None + try: + celery_task.apply_async( + args=[project_id, step_id], + queue=CeleryQueue.GEN_CHATAPI_CREATE.value, + countdown=0, + task_id=celery_task_id, + ) + except Exception as exc: + celery_error = exc + + if celery_error is None: + log_celery_dispatch_success(billing_context) + return + if registry_error is None: + log_celery_dispatch_failure( + billing_context, + error=f"Celery 直投失败,已保留 active registry 等待恢复:{celery_error}", + ) + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=project_id, + step_id=step_id, + user_id=_safe_user_id(current_user), + message="Celery 直投失败,任务将由 active registry 恢复投递", + detail={"recoverable": True, "celery_task_id": celery_task_id}, + error=str(celery_error), + ) + return + + await _mark_dispatch_failed_and_raise( + db, + current_user=current_user, + project_id=project_id, + step_id=step_id, + message=f"{error_prefix}: Redis 注册失败({registry_error});Celery 投递失败({celery_error})", + billing_context=billing_context, + ) + + @router.get( "/spec", response_model=ShotReplicateSpecOut, @@ -327,8 +535,16 @@ async def create_shot_task_set( ): _ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id")) try: - task_set = await create_task_set(db, current_user=current_user, req=req) - task_set_id = task_set.id + 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 任务由原投递或恢复任务继续处理。 + 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)) + celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}" + billing_context = build_task_set_analysis_billing_context(task_set) + billing_context.celery_task_id = celery_task_id await bind_upload_resources( db, user_id=current_user.id, @@ -348,11 +564,19 @@ async def create_shot_task_set( _log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}") raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}") + log_celery_dispatch_start(billing_context) try: from app.tasks.shot_replicate_tasks import analyze_original_video - analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0) + analyze_original_video.apply_async( + args=[task_set_id], + queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, + countdown=0, + task_id=celery_task_id, + ) + log_celery_dispatch_success(billing_context) except Exception as exc: + log_celery_dispatch_failure(billing_context, error=str(exc)) _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, @@ -361,6 +585,26 @@ async def create_shot_task_set( detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, exc=exc, ) + try: + compensated = await mark_task_set_analysis_dispatch_failed( + db, + current_user=_user_context(current_user), + task_set_id=task_set_id, + error_message=f"拆镜分析任务投递失败: {exc}", + ) + await db.commit() + if compensated: + log_celery_dispatch_compensated(billing_context, error=str(exc)) + except Exception as mark_exc: + await db.rollback() + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=task_set_id, + message="拆镜分析任务投递失败后补偿失败", + detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, + exc=mark_exc, + ) raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}") return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id) @@ -445,6 +689,16 @@ async def reanalyze_task_set( force=req.force, reason=req.reason, ) + analysis_attempt_no = int(out.analysis_attempt_no) + celery_task_id = f"shot-analysis:task-set:{task_set_id}:attempt:{analysis_attempt_no}" + billing_context = _analysis_dispatch_billing_context( + user_id=str(current_user.id), + owner_id=task_set_id, + attempt_no=analysis_attempt_no, + task_set_id=task_set_id, + is_segment=False, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException as exc: await db.rollback() @@ -470,10 +724,17 @@ async def reanalyze_task_set( ) raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}") + log_celery_dispatch_start(billing_context) try: from app.tasks.shot_replicate_tasks import analyze_original_video - analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0) + analyze_original_video.apply_async( + args=[task_set_id], + queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, + countdown=0, + task_id=celery_task_id, + ) + log_celery_dispatch_success(billing_context) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value, @@ -483,6 +744,7 @@ async def reanalyze_task_set( detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()}, ) except Exception as exc: + log_celery_dispatch_failure(billing_context, error=str(exc)) _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, @@ -491,6 +753,26 @@ async def reanalyze_task_set( detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, exc=exc, ) + try: + compensated = await mark_task_set_analysis_dispatch_failed( + db, + current_user=_user_context(current_user), + task_set_id=task_set_id, + error_message=f"原视频再次分析任务投递失败: {exc}", + ) + await db.commit() + if compensated: + log_celery_dispatch_compensated(billing_context, error=str(exc)) + except Exception as mark_exc: + await db.rollback() + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=task_set_id, + message="原视频再次分析任务投递失败后补偿失败", + detail={"task_set_id": task_set_id, "task": "analyze_original_video"}, + exc=mark_exc, + ) raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}") out.message = "原视频再次分析任务已提交" return out @@ -558,7 +840,38 @@ async def split_custom( from app.tasks.shot_replicate_tasks import split_one_segment - split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0) + try: + split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0) + except Exception as exc: + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + current_user=current_user, + project_id=task_set_id, + step_id=segment_id, + message=f"自定义拆镜切片任务投递失败: {exc}", + detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"}, + exc=exc, + ) + try: + await mark_custom_segment_split_dispatch_failed( + db, + current_user=_user_context(current_user), + segment_id=segment_id, + error_message=f"自定义拆镜切片任务投递失败: {exc}", + ) + await db.commit() + except Exception as mark_exc: + await db.rollback() + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=task_set_id, + step_id=segment_id, + message="自定义拆镜切片投递失败后补偿失败", + detail={"segment_id": segment_id, "task_set_id": task_set_id}, + exc=mark_exc, + ) + raise HTTPException(status_code=503, detail=f"自定义拆镜切片任务投递失败: {exc}") return out @@ -627,7 +940,17 @@ async def reanalyze_segment( force=req.force, reason=req.reason, ) - task_set_id = out.task_set_id + task_set_id = str(out.task_set_id) + analysis_attempt_no = int(out.analysis_attempt_no) + celery_task_id = f"shot-analysis:segment:{segment_id}:attempt:{analysis_attempt_no}" + billing_context = _analysis_dispatch_billing_context( + user_id=str(current_user.id), + owner_id=segment_id, + attempt_no=analysis_attempt_no, + task_set_id=task_set_id, + is_segment=True, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException as exc: await db.rollback() @@ -653,10 +976,17 @@ async def reanalyze_segment( ) raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}") + log_celery_dispatch_start(billing_context) try: from app.tasks.shot_replicate_tasks import analyze_custom_segment_video - analyze_custom_segment_video.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0) + analyze_custom_segment_video.apply_async( + args=[segment_id], + queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, + countdown=0, + task_id=celery_task_id, + ) + log_celery_dispatch_success(billing_context) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value, @@ -667,6 +997,7 @@ async def reanalyze_segment( detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()}, ) except Exception as exc: + log_celery_dispatch_failure(billing_context, error=str(exc)) _log_api_error( event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, current_user=current_user, @@ -676,6 +1007,27 @@ async def reanalyze_segment( detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"}, exc=exc, ) + try: + compensated = await mark_segment_analysis_dispatch_failed( + db, + current_user=_user_context(current_user), + segment_id=segment_id, + error_message=f"切片视频再次分析任务投递失败: {exc}", + ) + await db.commit() + if compensated: + log_celery_dispatch_compensated(billing_context, error=str(exc)) + except Exception as mark_exc: + await db.rollback() + _log_api_error( + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value, + current_user=current_user, + project_id=task_set_id, + step_id=segment_id, + message="切片视频再次分析任务投递失败后补偿失败", + detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"}, + exc=mark_exc, + ) raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}") out.message = "切片视频再次分析任务已提交" return out @@ -985,7 +1337,18 @@ async def generate_image_prompt( _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req) - project_id_value, step_id_value = project.id, step.id + project_id_value, step_id_value = str(project.id), str(step.id) + user_id_value = str(project.user_id) + attempt_no_value = int(step.version or 1) + celery_task_id = f"shot-replicate:image-prompt:{step_id_value}" + billing_context = _prompt_dispatch_billing_context( + user_id=user_id_value, + project_id=project_id_value, + step_id=step_id_value, + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + attempt_no=attempt_no_value, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException: await db.rollback() @@ -995,19 +1358,21 @@ async def generate_image_prompt( _log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}") - try: - from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize + from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize - await register_module_step_task( - module=MODULE, - project_id=project_id_value, - step_id=step_id_value, - step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, - task_name=TASK_SHOT_IMAGE_PROMPT, - ) - start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0) - except Exception as exc: - await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}") + log_celery_dispatch_start(billing_context) + await _dispatch_prompt_task( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + task_name=TASK_SHOT_IMAGE_PROMPT, + celery_task=start_image_prompt_optimize, + celery_task_id=celery_task_id, + billing_context=billing_context, + error_prefix="图片 AI 提词任务投递失败", + ) return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) @@ -1085,7 +1450,18 @@ async def generate_video_prompt( _ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id) try: project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req) - project_id_value, step_id_value = project.id, step.id + project_id_value, step_id_value = str(project.id), str(step.id) + user_id_value = str(project.user_id) + attempt_no_value = int(step.version or 1) + celery_task_id = f"shot-replicate:video-prompt:{step_id_value}" + billing_context = _prompt_dispatch_billing_context( + user_id=user_id_value, + project_id=project_id_value, + step_id=step_id_value, + step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + attempt_no=attempt_no_value, + celery_task_id=celery_task_id, + ) await db.commit() except HTTPException: await db.rollback() @@ -1095,19 +1471,21 @@ async def generate_video_prompt( _log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}") raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}") - try: - from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize + from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize - await register_module_step_task( - module=MODULE, - project_id=project_id_value, - step_id=step_id_value, - step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, - task_name=TASK_SHOT_VIDEO_PROMPT, - ) - start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0) - except Exception as exc: - await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}") + log_celery_dispatch_start(billing_context) + await _dispatch_prompt_task( + db, + current_user=current_user, + project_id=project_id_value, + step_id=step_id_value, + step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + task_name=TASK_SHOT_VIDEO_PROMPT, + celery_task=start_video_prompt_optimize, + celery_task_id=celery_task_id, + billing_context=billing_context, + error_prefix="视频 AI 提词任务投递失败", + ) return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value)) diff --git a/video-gen-api/app/api/v2/hot_opening_replicate.py b/video-gen-api/app/api/v2/hot_opening_replicate.py index ad89604d..e1e4bc2c 100644 --- a/video-gen-api/app/api/v2/hot_opening_replicate.py +++ b/video-gen-api/app/api/v2/hot_opening_replicate.py @@ -15,12 +15,15 @@ from app.schemas.module_generation_v2 import ( ) from app.services.generation.pipeline.enqueue_service import enqueue_generation_create from app.services.hot_opening_replicate_service import project_to_detail_out +from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated +from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock from app.services.module_generation_v2.config import HOT_OPENING_V2 from app.services.module_generation_v2.dispatch_service import ( dispatch_video_prompt_v2, ensure_v2_celery_enabled, ) from app.services.module_generation_v2.flow_service import ( + build_v2_video_prompt_billing_context, create_hot_opening_project_v2, delete_project_v2, generate_video_from_prompt_v2, @@ -35,6 +38,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com router = APIRouter(prefix="/hot-opening-replications", tags=["hot-opening-replications-v2"]) +def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext: + context = build_v2_video_prompt_billing_context( + user_id=user_id, + project_id=project_id, + step_id=step_id, + step_version=step_version, + module=HOT_OPENING_V2.module, + display_name=HOT_OPENING_V2.display_name, + ) + context.celery_task_id = f"module-v2-video-prompt:{step_id}" + return context + + async def _detail(db: AsyncSession, current_user: User, project_id: str) -> HotOpeningTaskDetailOut: project = await get_v2_project_for_user( db, @@ -50,14 +66,19 @@ async def _dispatch_or_mark_failed( *, project_id: str, step_id: str, + billing_context: LlmBillingContext, ) -> None: dispatch = await dispatch_video_prompt_v2( config=HOT_OPENING_V2, project_id=project_id, step_id=step_id, + billing_context=billing_context, ) if dispatch.recoverable: return + if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id): + # apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。 + return error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2" await mark_video_prompt_dispatch_failed_v2( db, @@ -66,6 +87,7 @@ async def _dispatch_or_mark_failed( step_id=step_id, error_message=error_message, ) + log_celery_dispatch_compensated(billing_context, error=error_message) raise HTTPException(status_code=503, detail=error_message) @@ -81,6 +103,12 @@ async def create_task_v2( project_id = str(result.project.id) step_id = str(result.prompt_step.id) created_new = bool(result.created_new) + billing_context = _dispatch_context( + user_id=str(result.project.user_id), + project_id=project_id, + step_id=step_id, + step_version=int(result.prompt_step.version or 1), + ) await db.commit() except IntegrityError as exc: await db.rollback() @@ -91,6 +119,12 @@ async def create_task_v2( project_id = str(result.project.id) step_id = str(result.prompt_step.id) created_new = bool(result.created_new) + billing_context = _dispatch_context( + user_id=str(result.project.user_id), + project_id=project_id, + step_id=step_id, + step_version=int(result.prompt_step.version or 1), + ) await db.commit() except HTTPException: await db.rollback() @@ -100,7 +134,9 @@ async def create_task_v2( raise HTTPException(status_code=500, detail="创建爆款复刻 V2 项目失败") from exc if created_new: - await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id) + await _dispatch_or_mark_failed( + db, project_id=project_id, step_id=step_id, billing_context=billing_context + ) return await _detail(db, current_user, project_id) @@ -136,11 +172,22 @@ async def retry_video_prompt_v2( ) project_id_value = str(project.id) step_id_value = str(new_step.id) + billing_context = _dispatch_context( + user_id=str(project.user_id), + project_id=project_id_value, + step_id=step_id_value, + step_version=int(new_step.version or 1), + ) await db.commit() except HTTPException: await db.rollback() raise - await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value) + await _dispatch_or_mark_failed( + db, + project_id=project_id_value, + step_id=step_id_value, + billing_context=billing_context, + ) return HotOpeningActionOut( message="视频提词已重新提交", project_id=project_id_value, diff --git a/video-gen-api/app/api/v2/shot_replicate.py b/video-gen-api/app/api/v2/shot_replicate.py index 7418d184..e6c03fe4 100644 --- a/video-gen-api/app/api/v2/shot_replicate.py +++ b/video-gen-api/app/api/v2/shot_replicate.py @@ -14,12 +14,15 @@ from app.schemas.module_generation_v2 import ( ) from app.schemas.shot_replicate import ShotReplicateActionOut, ShotReplicateDeleteOut, ShotReplicateTaskDetailOut from app.services.generation.pipeline.enqueue_service import enqueue_generation_create +from app.services.llm_billing import LlmBillingContext, log_celery_dispatch_compensated +from app.services.module_async_recovery_service import OBJECT_MODULE_STEP, has_live_object_lock from app.services.module_generation_v2.config import SHOT_REPLICATE_V2 from app.services.module_generation_v2.dispatch_service import ( dispatch_video_prompt_v2, ensure_v2_celery_enabled, ) from app.services.module_generation_v2.flow_service import ( + build_v2_video_prompt_billing_context, create_shot_replicate_project_v2, delete_project_v2, generate_video_from_prompt_v2, @@ -36,6 +39,19 @@ from app.services.upload_resource import cleanup_upload_resource_files_after_com router = APIRouter(prefix="/shot-replications", tags=["shot-replications-v2"]) +def _dispatch_context(*, user_id: str, project_id: str, step_id: str, step_version: int) -> LlmBillingContext: + context = build_v2_video_prompt_billing_context( + user_id=user_id, + project_id=project_id, + step_id=step_id, + step_version=step_version, + module=SHOT_REPLICATE_V2.module, + display_name=SHOT_REPLICATE_V2.display_name, + ) + context.celery_task_id = f"module-v2-video-prompt:{step_id}" + return context + + async def _detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut: project = await get_v2_project_for_user( db, @@ -51,14 +67,19 @@ async def _dispatch_or_mark_failed( *, project_id: str, step_id: str, + billing_context: LlmBillingContext, ) -> None: dispatch = await dispatch_video_prompt_v2( config=SHOT_REPLICATE_V2, project_id=project_id, step_id=step_id, + billing_context=billing_context, ) if dispatch.recoverable: return + if await has_live_object_lock(object_type=OBJECT_MODULE_STEP, object_id=step_id): + # apply_async 可能已送达但客户端收到异常;worker 已领取时不能释放冻结。 + return error_message = "视频提词任务的 Redis 注册和 Celery 投递均失败,请重新执行步骤2" await mark_video_prompt_dispatch_failed_v2( db, @@ -67,6 +88,7 @@ async def _dispatch_or_mark_failed( step_id=step_id, error_message=error_message, ) + log_celery_dispatch_compensated(billing_context, error=error_message) raise HTTPException(status_code=503, detail=error_message) @@ -91,6 +113,12 @@ async def create_project_v2( project_id = str(result.project.id) step_id = str(result.prompt_step.id) created_new = bool(result.created_new) + billing_context = _dispatch_context( + user_id=str(result.project.user_id), + project_id=project_id, + step_id=step_id, + step_version=int(result.prompt_step.version or 1), + ) await db.commit() except IntegrityError as exc: await db.rollback() @@ -105,6 +133,12 @@ async def create_project_v2( project_id = str(result.project.id) step_id = str(result.prompt_step.id) created_new = bool(result.created_new) + billing_context = _dispatch_context( + user_id=str(result.project.user_id), + project_id=project_id, + step_id=step_id, + step_version=int(result.prompt_step.version or 1), + ) await db.commit() except HTTPException: await db.rollback() @@ -114,7 +148,9 @@ async def create_project_v2( raise HTTPException(status_code=500, detail="创建拆镜复刻 V2 项目失败") from exc if created_new: - await _dispatch_or_mark_failed(db, project_id=project_id, step_id=step_id) + await _dispatch_or_mark_failed( + db, project_id=project_id, step_id=step_id, billing_context=billing_context + ) return ShotReplicateActionOut( message="V2 项目已创建,视频提词已自动提交" if created_new else "已返回现有幂等项目", project_id=project_id, @@ -155,11 +191,22 @@ async def retry_video_prompt_v2( ) project_id_value = str(project.id) step_id_value = str(new_step.id) + billing_context = _dispatch_context( + user_id=str(project.user_id), + project_id=project_id_value, + step_id=step_id_value, + step_version=int(new_step.version or 1), + ) await db.commit() except HTTPException: await db.rollback() raise - await _dispatch_or_mark_failed(db, project_id=project_id_value, step_id=step_id_value) + await _dispatch_or_mark_failed( + db, + project_id=project_id_value, + step_id=step_id_value, + billing_context=billing_context, + ) return ShotReplicateActionOut( message="视频提词已重新提交", project_id=project_id_value, diff --git a/video-gen-api/app/enums/common.py b/video-gen-api/app/enums/common.py index 2b1a19a8..04d87f99 100644 --- a/video-gen-api/app/enums/common.py +++ b/video-gen-api/app/enums/common.py @@ -124,3 +124,11 @@ VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12 MIN_GENERATION_COUNT = 1 MAX_GENERATION_COUNT = 5 + + + +class BillingBlockEventEnum(StrEnum): + """通用账务拦截日志事件。""" + + INSUFFICIENT_CREDITS = "BILLING_BLOCKED_INSUFFICIENT_CREDITS" + NEGATIVE_BALANCE = "BILLING_BLOCKED_NEGATIVE_BALANCE" diff --git a/video-gen-api/app/enums/credit_record.py b/video-gen-api/app/enums/credit_record.py index 9fe416ea..389c9674 100644 --- a/video-gen-api/app/enums/credit_record.py +++ b/video-gen-api/app/enums/credit_record.py @@ -56,6 +56,8 @@ class CreditRecordMediaType(str, Enum): class CreditRecordAction(str, Enum): CHARGE = "charge" REFUND = "refund" + HOLD = "hold" + HOLD_RELEASE = "hold_release" class CreditRecordSourceModule(str, Enum): @@ -112,6 +114,14 @@ class CreditRecordBillingScene(str, Enum): UNKNOWN = "unknown" + +CREDIT_RECORD_ACTION_LABELS = { + CreditRecordAction.CHARGE.value: "真实扣费", + CreditRecordAction.REFUND.value: "真实退款", + CreditRecordAction.HOLD.value: "预扣占用", + CreditRecordAction.HOLD_RELEASE.value: "预扣释放", +} + CREDIT_RECORD_TYPE_LABELS = { CreditRecordType.RECHARGE.value: "充值", CreditRecordType.CONSUME.value: "消费", diff --git a/video-gen-api/app/enums/llm_billing.py b/video-gen-api/app/enums/llm_billing.py new file mode 100644 index 00000000..a8f5c4ec --- /dev/null +++ b/video-gen-api/app/enums/llm_billing.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from enum import StrEnum + + +class LlmBillingConfigKey(StrEnum): + ENABLED = "llm_billing_enabled" + HOLD_DEFAULT = "llm_hold_credits_default" + HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt" + HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt" + HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt" + HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis" + LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits" + + +class LlmBillingLedgerState(StrEnum): + BILLING_BYPASSED = "billing_bypassed" + MISSING = "missing" + ACTIVE = "active" + RELEASED = "released" + CHARGED = "charged" + INVALID = "invalid" + + +class LlmBillingEvent(StrEnum): + HOLD_START = "LLM_HOLD_START" + HOLD_SUCCESS = "LLM_HOLD_SUCCESS" + HOLD_BYPASSED = "LLM_HOLD_BYPASSED" + HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT" + HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID" + HOLD_MISSING = "LLM_HOLD_MISSING" + HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START" + HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS" + HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED" + FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START" + FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS" + FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED" + EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START" + EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS" + EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED" + PROVIDER_START = "LLM_PROVIDER_START" + PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS" + PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE" + SETTLE_START = "LLM_SETTLE_START" + SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS" + CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS" + CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE" + SETTLE_FAILED = "LLM_SETTLE_FAILED" + CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START" + CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS" + CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE" + CELERY_DISPATCH_COMPENSATED = "LLM_CELERY_DISPATCH_COMPENSATED" + + +class LlmBillingDomain(StrEnum): + LLM_BILLING = "llm_billing" diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py index 74c76d28..143ee16d 100644 --- a/video-gen-api/app/main.py +++ b/video-gen-api/app/main.py @@ -186,6 +186,12 @@ async def _seed_data(): # Operation manual ("operation_manual", "", "操作手册链接"), ("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"), + ("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"), + ("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"), + ("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"), + ("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"), + ("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"), + ("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"), ] for key, value, desc in configs: existing = await db.execute( diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py index 2765e662..95e58d1a 100644 --- a/video-gen-api/app/schemas/admin.py +++ b/video-gen-api/app/schemas/admin.py @@ -205,6 +205,7 @@ class AdminCreditRecordOut(BaseModel): charge_kind: str | None = None charge_kind_label: str | None = None charge_action: str | None = None + charge_action_label: str | None = None credit_subject: str | None = None credit_subject_label: str | None = None media_type: str | None = None diff --git a/video-gen-api/app/schemas/shot_replicate.py b/video-gen-api/app/schemas/shot_replicate.py index 553cbb85..c858ba61 100644 --- a/video-gen-api/app/schemas/shot_replicate.py +++ b/video-gen-api/app/schemas/shot_replicate.py @@ -745,6 +745,7 @@ class ShotReanalyzeOut(BaseModel): message: str = Field(..., description="操作结果提示") task_set_id: str | None = Field(None, description="拆镜总任务集ID") segment_id: str | None = Field(None, description="拆镜片段ID") + analysis_attempt_no: int = Field(..., ge=1, description="本次分析 attempt 编号") analysis_status: str = Field(..., description="重置后的分析状态") celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名") 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 33e9deac..1223b7c4 100644 --- a/video-gen-api/app/services/admin_credit_record_service.py +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -7,6 +7,7 @@ from sqlalchemy import and_, case, distinct, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.credit_record import ( + CREDIT_RECORD_ACTION_LABELS, CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_CHARGE_KIND_LABELS, CREDIT_RECORD_MEDIA_TYPE_LABELS, @@ -89,6 +90,7 @@ def _build_filters( credit_subject: str | None = None, media_type: str | None = None, charge_kind: str | None = None, + charge_action: str | None = None, source_module: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, @@ -119,6 +121,8 @@ def _build_filters( filters.append(CreditRecord.media_type == media_type) if charge_kind: filters.append(CreditRecord.charge_kind == charge_kind) + if charge_action: + filters.append(CreditRecord.charge_action == charge_action) if source_module: filters.append(CreditRecord.source_module == source_module) if source_step_code: @@ -198,6 +202,7 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t "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), "credit_subject": record.credit_subject, "credit_subject_label": _label(CREDIT_RECORD_SUBJECT_LABELS, record.credit_subject), "media_type": record.media_type, @@ -237,6 +242,7 @@ async def list_admin_credit_records( credit_subject: str | None = None, media_type: str | None = None, charge_kind: str | None = None, + charge_action: str | None = None, source_module: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, @@ -255,6 +261,7 @@ async def list_admin_credit_records( credit_subject=credit_subject, media_type=media_type, charge_kind=charge_kind, + charge_action=charge_action, source_module=source_module, source_step_code=source_step_code, billing_scene=billing_scene, @@ -282,17 +289,17 @@ async def list_admin_credit_records( summary_query = select( func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), - func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.type.in_(["consume", "team_internal"]), (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0), func.count(CreditRecord.id), - func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), - func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), 1), else_=None)), - func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), - func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), - func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), - func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), - func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), - func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), 1), else_=None)), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume", (or_(CreditRecord.charge_action.is_(None), CreditRecord.charge_action == "charge"))), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(CreditRecord.total_tokens), 0), func.coalesce(func.sum(CreditRecord.input_tokens), 0), func.coalesce(func.sum(CreditRecord.output_tokens), 0), diff --git a/video-gen-api/app/services/credit_record_meta_service.py b/video-gen-api/app/services/credit_record_meta_service.py index 624bb309..f5f525d8 100644 --- a/video-gen-api/app/services/credit_record_meta_service.py +++ b/video-gen-api/app/services/credit_record_meta_service.py @@ -78,9 +78,20 @@ def _normalize_frontend_kind(value: str | None) -> str: return value or FrontendUserKind.EXTERNAL.value -async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id: str) -> CreditRecordMeta: - result = await db.execute(select(User).where(User.id == user_id).limit(1)) - user = result.scalar_one_or_none() +async def with_user_snapshot( + db: AsyncSession, + meta: CreditRecordMeta, + user_id: str, + *, + user: User | None = None, +) -> CreditRecordMeta: + """补充用户/团队冷备快照。 + + 积分服务已经通过 FOR UPDATE 载入 User 时应直接传入,避免同一扣费事务重复查询用户。 + """ + if user is None: + result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = result.scalar_one_or_none() if user: meta.user_type_snapshot = user.user_type meta.frontend_user_kind_snapshot = _normalize_frontend_kind(getattr(user, "frontend_user_kind", None)) diff --git a/video-gen-api/app/services/credits.py b/video-gen-api/app/services/credits.py index bd2f1909..6d377f12 100644 --- a/video-gen-api/app/services/credits.py +++ b/video-gen-api/app/services/credits.py @@ -1,26 +1,29 @@ import math +from dataclasses import dataclass from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.models.user import User from app.models.credit_record import CreditRecord -from app.models.system_config import SystemConfig from app.models.video_engine import VideoEngine from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio from app.utils.id_gen import generate_id from app.utils.exceptions import InsufficientCreditsError +from app.enums.common import BillingBlockEventEnum +from app.services.operation_log_service import log_operation_event +from app.services.system_config_cache import get_system_config_value from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float: - """Calculate text credits based on actual token usage and configurable rate.""" - result = await db.execute( - select(SystemConfig).where(SystemConfig.key == "text_credits_per_1000_tokens").limit(1) - ) - config = result.scalar_one_or_none() - rate = float(config.value) if config else 1.0 + """Calculate text credits based on actual token usage and cached configurable rate.""" + raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens") + try: + rate = float(raw_rate) if raw_rate not in (None, "") else 1.0 + except (TypeError, ValueError): + rate = 1.0 total_tokens = input_tokens + output_tokens return round(total_tokens * rate / 1000, 2) @@ -173,6 +176,16 @@ async def calc_image_credits( return round(total, 2) +@dataclass(slots=True) +class CreditMutationResult: + user: User + record: CreditRecord | None + created: bool + amount: float + balance_before: float + balance_after: float + + async def _get_existing_credit_record_by_biz_key( db: AsyncSession, *, @@ -190,7 +203,7 @@ async def _get_existing_credit_record_by_biz_key( return result.scalar_one_or_none() -async def deduct_credits( +async def deduct_credits_result( db: AsyncSession, user_id: str, amount: float, @@ -201,40 +214,72 @@ async def deduct_credits( refund_for_biz_key: str | None = None, record_meta: CreditRecordMeta | dict | None = None, record_type: str = "consume", -) -> User: - """扣减用户积分,并写入消费流水。 - - 并发安全点: - - 先用 SELECT ... FOR UPDATE 锁住 users 行,避免余额覆盖。 - - biz_key 不为空时,作为正式业务幂等键;重复调用直接返回当前用户,不重复扣。 - - record_type: 流水类型,默认 "consume";团队内部流转传 "team_internal"。 - """ + allow_negative: bool = False, + create_zero_record: bool = False, +) -> CreditMutationResult: + """并发安全且可观察幂等结果的积分扣减。""" amount = round(float(amount or 0), 2) - if amount <= 0: - result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1)) - user = result.scalar_one_or_none() - if not user: - raise ValueError("User not found") - return user - result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1)) user = result.scalar_one_or_none() if not user: raise ValueError("User not found") + before_balance = round(float(user.credits or 0), 2) if biz_key: existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) if existing: - return user + return CreditMutationResult( + user=user, + record=existing, + created=False, + amount=abs(round(float(existing.amount or 0), 2)), + balance_before=before_balance, + balance_after=before_balance, + ) - if float(user.credits or 0) < amount: + if amount <= 0 and not create_zero_record: + return CreditMutationResult( + user=user, + record=None, + created=False, + amount=0.0, + balance_before=before_balance, + balance_after=before_balance, + ) + + if amount > 0 and not allow_negative and before_balance < amount: + event_type = ( + BillingBlockEventEnum.NEGATIVE_BALANCE.value + if before_balance < 0 + else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value + ) + log_operation_event( + domain="billing", + module="credits", + event_type=event_type, + event_status="failed", + source="app.services.credits.deduct_credits_result", + user_id=user_id, + task_id=related_id, + message="积分不足,已拦截新的扣费请求", + detail={ + "user_id": user_id, + "amount": amount, + "before_balance": before_balance, + "allow_negative": allow_negative, + "biz_key": biz_key, + "refund_for_biz_key": refund_for_biz_key, + "description": description, + "record_type": record_type, + }, + ) raise InsufficientCreditsError() - user.credits = round(float(user.credits or 0) - amount, 2) - meta_kwargs = {} + user.credits = round(before_balance - max(0.0, amount), 2) + meta_kwargs: dict = {} if record_meta: if isinstance(record_meta, CreditRecordMeta): - record_meta = await with_user_snapshot(db, record_meta, user_id) + record_meta = await with_user_snapshot(db, record_meta, user_id, user=user) meta_kwargs = record_meta.to_record_kwargs() elif isinstance(record_meta, dict): meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} @@ -252,10 +297,48 @@ async def deduct_credits( ) db.add(record) await db.flush() - return user + return CreditMutationResult( + user=user, + record=record, + created=True, + amount=amount, + balance_before=before_balance, + balance_after=round(float(user.credits or 0), 2), + ) -async def add_credits( +async def deduct_credits( + db: AsyncSession, + user_id: str, + amount: float, + description: str, + related_id: str | None = None, + *, + biz_key: str | None = None, + refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, + record_type: str = "consume", + allow_negative: bool = False, + create_zero_record: bool = False, +) -> User: + """兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。""" + mutation = await deduct_credits_result( + db, + user_id=user_id, + amount=amount, + description=description, + related_id=related_id, + biz_key=biz_key, + refund_for_biz_key=refund_for_biz_key, + record_meta=record_meta, + record_type=record_type, + allow_negative=allow_negative, + create_zero_record=create_zero_record, + ) + return mutation.user + + +async def add_credits_result( db: AsyncSession, user_id: str, amount: float, @@ -266,31 +349,42 @@ async def add_credits( biz_key: str | None = None, refund_for_biz_key: str | None = None, record_meta: CreditRecordMeta | dict | None = None, -) -> User: - """增加用户积分,并写入流水。 - - record_type 默认保持原来的 recharge;生成失败回退时传 refund。 - biz_key 不为空时幂等,重复调用不会重复加积分。 - """ +) -> CreditMutationResult: + """并发安全且可观察幂等结果的积分增加。""" amount = round(float(amount or 0), 2) result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1)) user = result.scalar_one_or_none() if not user: raise ValueError("User not found") + before_balance = round(float(user.credits or 0), 2) if biz_key: existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key) if existing: - return user + return CreditMutationResult( + user=user, + record=existing, + created=False, + amount=abs(round(float(existing.amount or 0), 2)), + balance_before=before_balance, + balance_after=before_balance, + ) if amount <= 0: - return user + return CreditMutationResult( + user=user, + record=None, + created=False, + amount=0.0, + balance_before=before_balance, + balance_after=before_balance, + ) - user.credits = round(float(user.credits or 0) + amount, 2) - meta_kwargs = {} + user.credits = round(before_balance + amount, 2) + meta_kwargs: dict = {} if record_meta: if isinstance(record_meta, CreditRecordMeta): - record_meta = await with_user_snapshot(db, record_meta, user_id) + record_meta = await with_user_snapshot(db, record_meta, user_id, user=user) meta_kwargs = record_meta.to_record_kwargs() elif isinstance(record_meta, dict): meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} @@ -308,7 +402,41 @@ async def add_credits( ) db.add(record) await db.flush() - return user + return CreditMutationResult( + user=user, + record=record, + created=True, + amount=amount, + balance_before=before_balance, + balance_after=round(float(user.credits or 0), 2), + ) + + +async def add_credits( + db: AsyncSession, + user_id: str, + amount: float, + description: str, + related_id: str | None = None, + *, + record_type: str = "recharge", + biz_key: str | None = None, + refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, +) -> User: + """兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。""" + mutation = await add_credits_result( + db, + user_id=user_id, + amount=amount, + description=description, + related_id=related_id, + record_type=record_type, + biz_key=biz_key, + refund_for_biz_key=refund_for_biz_key, + record_meta=record_meta, + ) + return mutation.user async def refund_credits( diff --git a/video-gen-api/app/services/generation/billing_service.py b/video-gen-api/app/services/generation/billing_service.py index 5032979b..f1d8c6bc 100644 --- a/video-gen-api/app/services/generation/billing_service.py +++ b/video-gen-api/app/services/generation/billing_service.py @@ -21,7 +21,7 @@ from app.services.credit_record_meta_service import ( 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 +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 @@ -38,7 +38,7 @@ OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.va OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value _BIZ_KEY_PATTERN = re.compile( - r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund)$" + r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|hold|hold_release)$" ) @@ -63,7 +63,8 @@ class BillingSummary: return round(sum(item.amount for item in self.items if item.charged), 2) def get_amount(self, charge_key: str) -> float: - return round(sum(item.amount for item in self.items if item.charge_key == charge_key and item.charged), 2) + """返回该业务动作的已记录金额;幂等重放命中旧流水时也返回真实金额。""" + return round(sum(item.amount for item in self.items if item.charge_key == charge_key), 2) def to_dict(self) -> dict[str, Any]: data = asdict(self) @@ -100,8 +101,8 @@ def build_credit_biz_key( owner_id = owner_id.strip() charge_kind = charge_kind.strip() action = action.strip() - if action not in ("charge", "refund"): - raise ValueError("action 仅支持 charge/refund") + if action not in ("charge", "refund", "hold", "hold_release"): + raise ValueError("action 仅支持 charge/refund/hold/hold_release") if attempt_no <= 0: raise ValueError("attempt_no 必须大于 0") return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}" @@ -139,15 +140,6 @@ async def _calc_optional_token_credits(db: AsyncSession, tokens: int, config_key return round(tokens * rate / 1000, 2) -async def _find_existing_by_biz_key(db: AsyncSession, *, user_id: str, biz_key: str) -> CreditRecord | None: - result = await db.execute( - select(CreditRecord) - .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key) - .limit(1) - ) - return result.scalar_one_or_none() - - async def get_next_credit_attempt_no( db: AsyncSession, *, @@ -185,29 +177,25 @@ async def deduct_credits_locked_once( biz_key: str | None = None, attempt_no: int | None = None, record_meta: CreditRecordMeta | dict | None = None, + allow_negative: bool = False, ) -> BillingItem: """按 biz_key 做幂等扣费。 - charge_key 只保留为业务分类;正式幂等以 biz_key 为准。 - record_meta 负责把业务归属、模块、步骤、token、模型快照写入 CreditRecord。 + 幂等判断、用户行锁、余额更新和流水写入由 deduct_credits_result 在同一短事务内完成, + 避免先查一次 biz_key、加锁后再查一次的重复 SQL 和竞态窗口。 """ amount = _round2(amount) if amount <= 0: - return BillingItem(charge_key=charge_key, amount=0.0, charged=False, skipped_reason="amount_lte_zero", biz_key=biz_key, attempt_no=attempt_no) + return BillingItem( + charge_key=charge_key, + amount=0.0, + charged=False, + skipped_reason="amount_lte_zero", + biz_key=biz_key, + attempt_no=attempt_no, + ) - if biz_key: - existing_charge = await _find_existing_by_biz_key(db, user_id=user_id, biz_key=biz_key) - if existing_charge: - return BillingItem( - charge_key=charge_key, - amount=abs(_round2(existing_charge.amount)), - charged=False, - skipped_reason="already_charged", - biz_key=biz_key, - attempt_no=attempt_no, - ) - - await deduct_credits( + mutation = await deduct_credits_result( db, user_id=user_id, amount=amount, @@ -215,8 +203,16 @@ async def deduct_credits_locked_once( related_id=related_id, biz_key=biz_key, record_meta=record_meta, + allow_negative=allow_negative, + ) + return BillingItem( + charge_key=charge_key, + amount=mutation.amount if not mutation.created else amount, + charged=mutation.created, + skipped_reason=None if mutation.created else "already_charged", + biz_key=biz_key, + attempt_no=attempt_no, ) - return BillingItem(charge_key=charge_key, amount=amount, charged=True, biz_key=biz_key, attempt_no=attempt_no) async def charge_chatapi_prompt_usage( 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 3166a56e..b8476710 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum from app.models.chat_generation_task import ChatGenerationTask from app.models.module_generation_project import ModuleGenerationProject @@ -41,7 +43,6 @@ from app.services.generation.ai.engine_service import ( get_video_engine, parse_json_list, ) -from app.services.generation.billing_service import charge_module_prompt_usage from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.pipeline.db_lock_service import ( DatabaseRowLockBusy, @@ -52,7 +53,18 @@ from app.services.generation.task_factory_service import create_chat_generation_ from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event from app.services.llm import optimize_prompt +from app.services.llm_billing import ( + LlmBillingContext, + ensure_hold_exists, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, + start_hold, +) 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, chat_tasks_by_id as _base_chat_tasks_by_id, create_module_step as _base_create_step, get_current_step_by_code as _base_get_current_step_by_code, @@ -870,6 +882,25 @@ async def submit_image_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.error_message = None + await start_hold( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value, + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + related_id=str(step.id), + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + description_prefix="爆款开头复刻图片AI提词优化", + trace_id=f"llm-submit-hold:{step.id}", + ), + ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") return project, step @@ -952,8 +983,37 @@ async def run_image_prompt_optimize( module_value = str(project.module) expected_step_version = int(step.version or 1) expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str) + llm_billing_context = LlmBillingContext( + user_id=user_id_value, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id_value, + attempt_no=expected_step_version, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value, + source_module=module_value, + source_project_id=project_id_value, + source_step_id=step_id_value, + source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + related_id=step_id_value, + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + description_prefix="爆款开头复刻图片AI提词优化", + trace_id=f"hot-opening-image-prompt:{step_id_value}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message) + await db.commit() + return step await db.commit() + provider_succeeded = False + token_usage: dict[str, Any] = {} + log_provider_start(llm_billing_context, detail={"prompt_type": "image"}) try: request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} log_module_prompt_event( @@ -979,6 +1039,8 @@ async def run_image_prompt_optimize( log_owner_id=step_id_value, generation_attempt_no=expected_step_version, ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -992,19 +1054,26 @@ async def run_image_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await db.rollback() + # Provider 已成功,旧步骤即使失效也必须按真实 usage 结算,不能免费释放。 + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-图片AI提词优化(失效结果结算)", + ) + await db.commit() return None - billing = await charge_module_prompt_usage( + billing = await settle_success( db, - user_id=project.user_id, - step_id=step.id, + llm_billing_context, usage=token_usage, description="爆款开头复刻-图片AI提词优化", ) + actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None) usage = dict(token_usage or {}) usage.update({ - "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), - "credit_biz_key": billing.items[0].biz_key if billing.items else None, + "text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value), + "credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None, }) step.status = ModuleStepStatusEnum.COMPLETED.value step.completed_at = _now() @@ -1041,9 +1110,22 @@ async def run_image_prompt_optimize( await db.commit() except DatabaseRowLockBusy: await db.rollback() + if provider_succeeded: + # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-图片AI提词优化(行锁失败结算)", + ) + await db.commit() + return None + # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() + if not provider_succeeded: + log_provider_failure(llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1058,6 +1140,16 @@ async def run_image_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-图片AI提词优化(异常失效结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") + await db.commit() return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) @@ -1076,6 +1168,15 @@ async def run_image_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-图片AI提词优化(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() return step @@ -1240,6 +1341,25 @@ async def submit_video_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.error_message = None + await start_hold( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value, + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + related_id=str(step.id), + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix="爆款开头复刻视频AI提词优化", + trace_id=f"llm-submit-hold:{step.id}", + ), + ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") return project, step @@ -1320,8 +1440,37 @@ async def run_video_prompt_optimize( module_value = str(project.module) expected_step_version = int(step.version or 1) expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str) + llm_billing_context = LlmBillingContext( + user_id=user_id_value, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id_value, + attempt_no=expected_step_version, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value, + source_module=module_value, + source_project_id=project_id_value, + source_step_id=step_id_value, + source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + related_id=step_id_value, + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix="爆款开头复刻视频AI提词优化", + trace_id=f"hot-opening-video-prompt:{step_id_value}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message) + await db.commit() + return step await db.commit() + provider_succeeded = False + token_usage: dict[str, Any] = {} + log_provider_start(llm_billing_context, detail={"prompt_type": "video"}) try: request_log = { "source_project_name": material.get("source_project_name") or "无", @@ -1359,6 +1508,8 @@ async def run_video_prompt_optimize( step_id=step_id_value, trace_id=f"hot-video-prompt:{step_id_value}", ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1372,19 +1523,25 @@ async def run_video_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await db.rollback() + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-视频AI提词优化(失效结果结算)", + ) + await db.commit() return None - billing = await charge_module_prompt_usage( + billing = await settle_success( db, - user_id=project.user_id, - step_id=step.id, + llm_billing_context, usage=token_usage, description="爆款开头复刻-视频AI提词优化", ) + actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None) usage = dict(token_usage or {}) usage.update({ - "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), - "credit_biz_key": billing.items[0].biz_key if billing.items else None, + "text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value), + "credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None, }) step.status = ModuleStepStatusEnum.COMPLETED.value step.completed_at = _now() @@ -1424,9 +1581,22 @@ async def run_video_prompt_optimize( await db.commit() except DatabaseRowLockBusy: await db.rollback() + if provider_succeeded: + # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-视频AI提词优化(行锁失败结算)", + ) + await db.commit() + return None + # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() + if not provider_succeeded: + log_provider_failure(llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1441,6 +1611,16 @@ async def run_video_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-视频AI提词优化(异常失效结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") + await db.commit() return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) @@ -1459,6 +1639,15 @@ async def run_video_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="爆款开头复刻-视频AI提词优化(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() return step @@ -1746,6 +1935,39 @@ async def mark_hot_opening_step_dispatch_failed( step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = error_message + if step.step_code in (HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value): + await release_on_failure( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value + if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=str(step.step_code), + related_id=str(step.id), + hold_config_key=( + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value + if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value + ), + description_prefix=( + "爆款开头复刻图片AI提词优化" + if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else "爆款开头复刻视频AI提词优化" + ), + trace_id=f"hot-opening-dispatch-failed:{step.id}", + ), + error=error_message, + ) log_module_error( module=project.module, event_type="CELERY_DISPATCH_FAILED", @@ -1768,6 +1990,25 @@ async def mark_hot_opening_step_dispatch_failed( async def delete_hot_opening_project(db: AsyncSession, *, current_user: User, project_id: str) -> HotOpeningDeleteOut: project = await _get_project_for_user(db, project_id=project_id, user=current_user, for_update=True) + processing_result = await db.execute( + select(func.count()) + .select_from(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.status == ModuleStepStatusEnum.PROCESSING.value, + ) + ) + if int(processing_result.scalar() or 0) > 0: + raise HTTPException(status_code=409, detail="当前爆款开头复刻项目仍有 AI 任务处理中,暂不能删除") + await _base_assert_project_has_no_active_chat_tasks( + db, + project=project, + config=FLOW_CONFIG, + detail_message="当前爆款开头复刻项目仍有生成中任务,暂不能删除", + ) project_id_snapshot = project.id deleted_at = _now() project.deleted_at = deleted_at diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py index 464c675f..7d6d3ada 100644 --- a/video-gen-api/app/services/hot_opening_video_prompt_service.py +++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py @@ -1571,19 +1571,8 @@ async def optimize_hot_opening_video_prompt( call_id = generate_id() duration = int(video_config["duration"]) from app.utils.media import media_to_base64, get_llm_media_as_base64 + use_base64 = await get_llm_media_as_base64(db) - if use_base64: - video_url_final = await media_to_base64(material_video_url, "video/mp4") - else: - video_url_final = _build_file_url_or_data_uri(material_video_url) - references = [{"type": "video", "url": video_url_final}] - if generated_image_url: - image_url_final = ( - await media_to_base64(generated_image_url, "image/png") - if use_base64 - else _build_file_url_or_data_uri(generated_image_url) - ) - references.append({"type": "image", "url": image_url_final}) client_schema = build_dynamic_schema(video_config, schema_config_snapshot) reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS) @@ -1605,14 +1594,26 @@ async def optimize_hot_opening_video_prompt( if config_row is not None else None ) - # All module/project claims are committed by the caller. Release this - # configuration read transaction before the remote model request and use - # only the scalar snapshot afterwards. - await db.commit() + # 调用方已提交业务 claim。模型配置和媒体传输开关读取完成后立即释放 + # 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。 + await db.rollback() if not config: result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot) return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} + if use_base64: + video_url_final = await media_to_base64(material_video_url, "video/mp4") + else: + video_url_final = _build_file_url_or_data_uri(material_video_url) + references = [{"type": "video", "url": video_url_final}] + if generated_image_url: + image_url_final = ( + await media_to_base64(generated_image_url, "image/png") + if use_base64 + else _build_file_url_or_data_uri(generated_image_url) + ) + references.append({"type": "image", "url": image_url_final}) + user_text = build_user_text( source_project_name=source_project_name, target_project_name=target_project_name, diff --git a/video-gen-api/app/services/llm.py b/video-gen-api/app/services/llm.py index a0678fbf..e321812e 100644 --- a/video-gen-api/app/services/llm.py +++ b/video-gen-api/app/services/llm.py @@ -238,14 +238,14 @@ async def _call_openai_compatible( user_content: str, image_urls: list[str], video_urls: list[str], + *, + as_base64: bool, ) -> tuple[dict, dict | None]: """构建多模态 user_message。返回 (actual_message, log_message)。""" from app.utils.media import media_to_base64 content_parts = [{"type": "text", "text": user_content}] - from app.utils.media import get_llm_media_as_base64 - as_base64 = await get_llm_media_as_base64(db) for img in image_urls: if as_base64: url = await media_to_base64(img, "image/png") @@ -303,8 +303,18 @@ async def _call_openai_compatible( return f"{base}{path}" if image_urls or video_urls: + from app.utils.media import get_llm_media_as_base64 + + as_base64 = await get_llm_media_as_base64(db) + # 配置读取后立即结束事务;后续 URL 下载/Base64 转换属于外部 I/O, + # 不能继续占用数据库连接。 + if db is not None: + await db.commit() user_message, log_user_message = await _build_multimodal_content( - user_content, image_urls, video_urls + user_content, + image_urls, + video_urls, + as_base64=as_base64, ) else: user_message = { @@ -313,6 +323,10 @@ async def _call_openai_compatible( } log_user_message = None + # 防御性结束可能由配置读取开启的只读事务;HTTP 请求期间不占用数据库连接。 + if db is not None: + await db.commit() + async with httpx.AsyncClient(timeout=120) as client: request_data = { "model": config.model_name, diff --git a/video-gen-api/app/services/llm_billing/__init__.py b/video-gen-api/app/services/llm_billing/__init__.py new file mode 100644 index 00000000..e83b01f3 --- /dev/null +++ b/video-gen-api/app/services/llm_billing/__init__.py @@ -0,0 +1,43 @@ +from app.services.llm_billing.context import ( + LlmBillingConfigurationError, + LlmBillingContext, + LlmBillingPolicy, + LlmBillingStateError, + LlmHoldResult, + LlmHoldValidation, +) +from app.services.llm_billing.service import ( + ensure_hold_exists, + get_llm_ledger_states, + log_celery_dispatch_compensated, + log_celery_dispatch_failure, + log_celery_dispatch_start, + log_celery_dispatch_success, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, + start_hold, +) + +__all__ = [ + "LlmBillingConfigurationError", + "LlmBillingContext", + "LlmBillingPolicy", + "LlmBillingStateError", + "LlmHoldResult", + "LlmHoldValidation", + "start_hold", + "ensure_hold_exists", + "get_llm_ledger_states", + "log_provider_start", + "log_provider_success", + "log_provider_failure", + "log_celery_dispatch_start", + "log_celery_dispatch_success", + "log_celery_dispatch_failure", + "log_celery_dispatch_compensated", + "settle_success", + "release_on_failure", +] diff --git a/video-gen-api/app/services/llm_billing/config.py b/video-gen-api/app/services/llm_billing/config.py new file mode 100644 index 00000000..0426060f --- /dev/null +++ b/video-gen-api/app/services/llm_billing/config.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.llm_billing import LlmBillingConfigKey +from app.services.llm_billing.context import LlmBillingPolicy +from app.services.system_config_cache import get_system_config_values + +_DEFAULT_HOLD_CREDITS = 5.0 +_FALSE_VALUES = {"0", "false", "no", "off", "disabled"} +_HOLD_CONFIG_KEYS = { + LlmBillingConfigKey.HOLD_DEFAULT.value, + LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value, +} + + +def _parse_bool(value: str | None, *, default: bool = True) -> bool: + if value is None or str(value).strip() == "": + return default + return str(value).strip().lower() not in _FALSE_VALUES + + +def _parse_float(value: str | float | int | None) -> float | None: + try: + if value is None or str(value).strip() == "": + return None + return round(float(value), 2) + except (TypeError, ValueError): + return None + + +def is_llm_hold_config_key(key: str | None) -> bool: + return bool(key and key in _HOLD_CONFIG_KEYS) + + +async def get_llm_billing_policy( + db: AsyncSession, + *, + config_key: str | None = None, + explicit_hold_credits: float | None = None, + default: float = _DEFAULT_HOLD_CREDITS, +) -> LlmBillingPolicy: + keys = [LlmBillingConfigKey.ENABLED.value] + if config_key: + keys.append(config_key) + keys.extend( + [ + LlmBillingConfigKey.HOLD_DEFAULT.value, + LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value, + ] + ) + # 去重并保持优先级;一次读取避免 enabled/scene/default 分散查询。 + ordered_keys = list(dict.fromkeys(keys)) + values = await get_system_config_values(db, ordered_keys) + enabled = _parse_bool(values.get(LlmBillingConfigKey.ENABLED.value), default=True) + if not enabled: + return LlmBillingPolicy(enabled=False, hold_credits=0.0, config_key=config_key) + + if explicit_hold_credits is not None: + amount = _parse_float(explicit_hold_credits) + source_key = "explicit" + else: + amount = None + source_key = None + for key in ordered_keys[1:]: + parsed = _parse_float(values.get(key)) + if parsed is not None: + amount = parsed + source_key = key + break + if amount is None: + amount = round(float(default), 2) + source_key = "default" + + if amount is None or amount <= 0: + return LlmBillingPolicy( + enabled=True, + hold_credits=float(amount or 0), + config_key=config_key, + source_key=source_key, + valid=False, + error="启用LLM统一计费时,预扣积分必须大于0", + ) + return LlmBillingPolicy( + enabled=True, + hold_credits=round(float(amount), 2), + config_key=config_key, + source_key=source_key, + ) + + +async def get_llm_hold_credits( + db: AsyncSession, + *, + config_key: str | None = None, + default: float = _DEFAULT_HOLD_CREDITS, +) -> float: + policy = await get_llm_billing_policy(db, config_key=config_key, default=default) + return policy.hold_credits + + +async def is_llm_billing_enabled(db: AsyncSession) -> bool: + return (await get_llm_billing_policy(db)).enabled + + +async def validate_llm_system_config_value( + db: AsyncSession, + *, + key: str, + value: str, +) -> None: + """校验后台单项更新,避免启用计费时保存零或负数预扣。""" + if key == LlmBillingConfigKey.ENABLED.value: + if not _parse_bool(value, default=True): + return + keys = [ + LlmBillingConfigKey.HOLD_DEFAULT.value, + LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value, + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + ] + values = await get_system_config_values(db, keys, ttl_seconds=1) + invalid = [ + config_name + for config_name in keys + if (raw_value := values.get(config_name)) is not None + and str(raw_value).strip() != "" + and ((parsed := _parse_float(raw_value)) is None or parsed <= 0) + ] + if invalid: + raise ValueError(f"启用LLM统一计费前,请先将以下预扣配置设置为大于0:{', '.join(invalid)}") + return + + if not is_llm_hold_config_key(key): + return + parsed = _parse_float(value) + enabled_values = await get_system_config_values(db, [LlmBillingConfigKey.ENABLED.value], ttl_seconds=1) + enabled = _parse_bool(enabled_values.get(LlmBillingConfigKey.ENABLED.value), default=True) + if enabled and (parsed is None or parsed <= 0): + raise ValueError("启用LLM统一计费时,预扣积分必须大于0") diff --git a/video-gen-api/app/services/llm_billing/context.py b/video-gen-api/app/services/llm_billing/context.py new file mode 100644 index 00000000..d94f0952 --- /dev/null +++ b/video-gen-api/app/services/llm_billing/context.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.enums.credit_record import CreditRecordChargeKind +from app.enums.llm_billing import LlmBillingLedgerState +from app.services.generation.billing_service import build_credit_biz_key + + +class LlmBillingConfigurationError(RuntimeError): + """LLM 统一账务配置无效,必须在调用模型前终止。""" + + +class LlmBillingStateError(RuntimeError): + """当前 attempt 的账务流水状态不允许继续执行。""" + + +@dataclass(slots=True, frozen=True) +class LlmBillingPolicy: + enabled: bool + hold_credits: float + config_key: str | None = None + source_key: str | None = None + valid: bool = True + error: str | None = None + + @property + def bypassed(self) -> bool: + return not self.enabled + + +@dataclass(slots=True) +class LlmBillingContext: + user_id: str + owner_type: str + owner_id: str + attempt_no: int + charge_kind: str = CreditRecordChargeKind.TEXT_PROMPT.value + billing_scene: str | None = None + source_module: str | None = None + source_project_id: str | None = None + source_step_id: str | None = None + source_step_code: str | None = None + related_id: str | None = None + hold_credits: float | None = None + hold_config_key: str | None = None + description_prefix: str = "LLM" + trace_id: str | None = None + request_id: str | None = None + celery_task_id: str | None = None + provider: str | None = None + model_name: str | None = None + token_usage_id: str | None = None + + @property + def hold_biz_key(self) -> str: + return build_credit_biz_key( + owner_type=self.owner_type, + owner_id=self.owner_id, + attempt_no=self.attempt_no, + charge_kind=self.charge_kind, + action="hold", + ) + + @property + def hold_release_biz_key(self) -> str: + return build_credit_biz_key( + owner_type=self.owner_type, + owner_id=self.owner_id, + attempt_no=self.attempt_no, + charge_kind=self.charge_kind, + action="hold_release", + ) + + @property + def charge_biz_key(self) -> str: + return build_credit_biz_key( + owner_type=self.owner_type, + owner_id=self.owner_id, + attempt_no=self.attempt_no, + charge_kind=self.charge_kind, + action="charge", + ) + + @property + def ledger_biz_keys(self) -> tuple[str, str, str]: + return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key + + +@dataclass(slots=True, frozen=True) +class LlmHoldResult: + amount: float + state: LlmBillingLedgerState + created: bool = False + record_id: str | None = None + reason: str | None = None + + @property + def bypassed(self) -> bool: + return self.state == LlmBillingLedgerState.BILLING_BYPASSED + + +@dataclass(slots=True, frozen=True) +class LlmHoldValidation: + can_execute: bool + amount: float + state: LlmBillingLedgerState + reason: str | None = None + hold_record_id: str | None = None + + @property + def bypassed(self) -> bool: + return self.state == LlmBillingLedgerState.BILLING_BYPASSED diff --git a/video-gen-api/app/services/llm_billing/service.py b/video-gen-api/app/services/llm_billing/service.py new file mode 100644 index 00000000..2e663d2d --- /dev/null +++ b/video-gen-api/app/services/llm_billing/service.py @@ -0,0 +1,882 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_record import ( + CreditRecordAction, + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, + CreditRecordSourceModule, + CreditRecordSubject, +) +from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingLedgerState +from app.models.credit_record import CreditRecord +from app.models.module_generation_step import ModuleGenerationStep +from app.models.token_usage import TokenUsage +from app.services.credit_record_meta_service import ( + CreditRecordMeta, + build_generation_record_prompt_meta, + build_module_step_prompt_meta, + build_shot_video_analysis_meta, +) +from app.services.credits import add_credits_result, calc_text_credits, deduct_credits_result +from app.services.generation.billing_service import BillingItem, BillingSummary +from app.services.llm_billing.config import get_llm_billing_policy +from app.services.llm_billing.context import ( + LlmBillingConfigurationError, + LlmBillingContext, + LlmBillingStateError, + LlmHoldResult, + LlmHoldValidation, +) +from app.services.operation_log_service import log_operation_event +from app.utils.exceptions import InsufficientCreditsError +from app.utils.id_gen import generate_id + +_LEDGER_QUERY_BATCH_SIZE = 1000 + + +@dataclass(slots=True) +class _LedgerRecords: + state: LlmBillingLedgerState + hold: CreditRecord | None = None + release: CreditRecord | None = None + charge: CreditRecord | None = None + reason: str | None = None + + @property + def hold_amount(self) -> float: + return _round2(abs(float(self.hold.amount or 0))) if self.hold else 0.0 + + + +def _round2(value: Any) -> float: + try: + return round(float(value or 0), 2) + except (TypeError, ValueError): + return 0.0 + + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + if value is None or value == "": + return default + return int(value) + except (TypeError, ValueError): + return default + + + +def _context_detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]: + detail = { + "user_id": ctx.user_id, + "owner_type": ctx.owner_type, + "owner_id": ctx.owner_id, + "attempt_no": ctx.attempt_no, + "charge_kind": ctx.charge_kind, + "billing_scene": ctx.billing_scene, + "source_module": ctx.source_module, + "source_project_id": ctx.source_project_id, + "source_step_id": ctx.source_step_id, + "source_step_code": ctx.source_step_code, + "related_id": ctx.related_id, + "hold_biz_key": ctx.hold_biz_key, + "hold_release_biz_key": ctx.hold_release_biz_key, + "charge_biz_key": ctx.charge_biz_key, + "celery_task_id": ctx.celery_task_id, + "provider": ctx.provider, + "model_name": ctx.model_name, + "token_usage_id": ctx.token_usage_id, + } + detail.update({key: value for key, value in extra.items() if value is not None}) + return {key: value for key, value in detail.items() if value is not None} + + + +def _log( + ctx: LlmBillingContext, + event: LlmBillingEvent, + *, + status: str = "success", + message: str | None = None, + detail: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + log_operation_event( + domain=LlmBillingDomain.LLM_BILLING.value, + module=ctx.source_module or LlmBillingDomain.LLM_BILLING.value, + event_type=event.value, + event_status=status, + source="app.services.llm_billing.service", + trace_id=ctx.trace_id, + request_id=ctx.request_id, + user_id=ctx.user_id, + project_id=ctx.source_project_id, + task_id=ctx.owner_id, + step_id=ctx.source_step_id, + message=message, + detail=detail or _context_detail(ctx), + error=error, + ) + + + +def log_provider_start(ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None: + _log( + ctx, + LlmBillingEvent.PROVIDER_START, + status="started", + detail=_context_detail(ctx, **dict(detail or {})), + ) + + +def log_provider_success(ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None: + usage_snapshot = dict(usage or {}) + ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider + ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name + ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id + _log( + ctx, + LlmBillingEvent.PROVIDER_SUCCESS, + detail=_context_detail( + ctx, + input_tokens=_safe_int(usage_snapshot.get("input_tokens")), + output_tokens=_safe_int(usage_snapshot.get("output_tokens")), + total_tokens=_safe_int(usage_snapshot.get("total_tokens")), + ), + ) + + +def log_provider_failure(ctx: LlmBillingContext, *, error: str) -> None: + _log( + ctx, + LlmBillingEvent.PROVIDER_FAILURE, + status="failed", + detail=_context_detail(ctx, error_type="provider_call_failed"), + error=error, + ) + + +def log_celery_dispatch_start(ctx: LlmBillingContext) -> None: + _log(ctx, LlmBillingEvent.CELERY_DISPATCH_START, status="started") + + +def log_celery_dispatch_success(ctx: LlmBillingContext) -> None: + _log(ctx, LlmBillingEvent.CELERY_DISPATCH_SUCCESS) + + +def log_celery_dispatch_failure(ctx: LlmBillingContext, *, error: str) -> None: + _log(ctx, LlmBillingEvent.CELERY_DISPATCH_FAILURE, status="failed", error=error) + + +def log_celery_dispatch_compensated(ctx: LlmBillingContext, *, error: str) -> None: + _log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, detail=_context_detail(ctx, compensation_error=error)) + + +def _hold_meta(ctx: LlmBillingContext, *, action: str) -> CreditRecordMeta: + subject = ( + CreditRecordSubject.ANALYSIS.value + if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value + else CreditRecordSubject.TEXT.value + ) + return CreditRecordMeta( + owner_type=ctx.owner_type, + owner_id=ctx.owner_id, + attempt_no=ctx.attempt_no, + charge_kind=ctx.charge_kind, + charge_action=action, + credit_subject=subject, + media_type="video" if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value else None, + billing_scene=ctx.billing_scene, + source_module=ctx.source_module, + source_project_id=ctx.source_project_id, + source_step_id=ctx.source_step_id, + source_step_code=ctx.source_step_code, + ) + + + +def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) -> bool: + if record is None: + return True + # 兼容旧数据:正式 biz_key 已明确动作、charge_action 为空时仍可识别。 + return record.charge_action in (None, expected.value) + + + +def _classify_ledger( + *, + hold: CreditRecord | None, + release: CreditRecord | None, + charge: CreditRecord | None, +) -> _LedgerRecords: + if not _action_valid(hold, CreditRecordAction.HOLD): + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_action_mismatch") + if not _action_valid(release, CreditRecordAction.HOLD_RELEASE): + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch") + if not _action_valid(charge, CreditRecordAction.CHARGE): + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch") + if hold is None: + if release is not None or charge is not None: + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup") + return _LedgerRecords(LlmBillingLedgerState.MISSING) + if release is None and charge is None: + if _round2(abs(float(hold.amount or 0))) <= 0: + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive") + return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold) + if release is not None and charge is None: + return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release) + if release is not None and charge is not None: + return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge) + return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release") + + +async def _load_ledgers( + db: AsyncSession, + contexts: Iterable[LlmBillingContext], +) -> dict[str, _LedgerRecords]: + context_list = list(contexts) + if not context_list: + return {} + record_map: dict[tuple[str, str], CreditRecord] = {} + all_keys = list(dict.fromkeys(key for ctx in context_list for key in ctx.ledger_biz_keys)) + user_ids = list(dict.fromkeys(ctx.user_id for ctx in context_list)) + for offset in range(0, len(all_keys), _LEDGER_QUERY_BATCH_SIZE): + chunk = all_keys[offset : offset + _LEDGER_QUERY_BATCH_SIZE] + result = await db.execute( + select(CreditRecord).where( + CreditRecord.user_id.in_(user_ids), + CreditRecord.biz_key.in_(chunk), + ) + ) + for record in result.scalars().all(): + if record.biz_key: + record_map[(str(record.user_id), str(record.biz_key))] = record + + output: dict[str, _LedgerRecords] = {} + for ctx in context_list: + hold = record_map.get((ctx.user_id, ctx.hold_biz_key)) + release = record_map.get((ctx.user_id, ctx.hold_release_biz_key)) + charge = record_map.get((ctx.user_id, ctx.charge_biz_key)) + output[ctx.hold_biz_key] = _classify_ledger(hold=hold, release=release, charge=charge) + return output + + +async def _load_ledger(db: AsyncSession, ctx: LlmBillingContext) -> _LedgerRecords: + return (await _load_ledgers(db, [ctx]))[ctx.hold_biz_key] + + +async def get_llm_ledger_states( + db: AsyncSession, + contexts: Iterable[LlmBillingContext], +) -> dict[str, LlmHoldValidation]: + """批量读取 attempt 的三类流水;供恢复任务收集 ID 后统一过滤。""" + context_list = list(contexts) + ledgers = await _load_ledgers(db, context_list) + return { + ctx.hold_biz_key: LlmHoldValidation( + can_execute=ledgers[ctx.hold_biz_key].state == LlmBillingLedgerState.ACTIVE, + amount=ledgers[ctx.hold_biz_key].hold_amount, + state=ledgers[ctx.hold_biz_key].state, + reason=ledgers[ctx.hold_biz_key].reason, + hold_record_id=ledgers[ctx.hold_biz_key].hold.id if ledgers[ctx.hold_biz_key].hold else None, + ) + for ctx in context_list + } + + +async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult: + # 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。 + ledger = await _load_ledger(db, ctx) + + # 配置可能在任务执行期间被关闭或修改:已经存在的 active HOLD 必须继续沿用, + # 否则会留下永久冻结流水。只有“没有任何历史流水”的新 attempt 才允许按关闭配置绕过。 + if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: + amount = ledger.hold_amount + ctx.hold_credits = amount + _log( + ctx, + LlmBillingEvent.HOLD_SUCCESS, + detail=_context_detail( + ctx, + hold_credits=amount, + hold_record_id=ledger.hold.id, + idempotent=True, + ledger_state=ledger.state.value, + ), + ) + return LlmHoldResult(amount, ledger.state, created=False, record_id=ledger.hold.id) + + if ledger.state != LlmBillingLedgerState.MISSING: + error = f"当前attempt账务状态为{ledger.state.value},不能复用旧预扣" + _log( + ctx, + LlmBillingEvent.EXECUTION_BLOCKED, + status="failed", + detail=_context_detail(ctx, ledger_state=ledger.state.value, reason=ledger.reason), + error=error, + ) + raise LlmBillingStateError(error) + + policy = await get_llm_billing_policy( + db, + config_key=ctx.hold_config_key, + explicit_hold_credits=ctx.hold_credits, + ) + if policy.bypassed: + _log( + ctx, + LlmBillingEvent.HOLD_BYPASSED, + status="skipped", + detail=_context_detail(ctx, ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value), + ) + return LlmHoldResult( + 0.0, + LlmBillingLedgerState.BILLING_BYPASSED, + reason="billing_disabled", + ) + if not policy.valid: + _log( + ctx, + LlmBillingEvent.HOLD_CONFIG_INVALID, + status="failed", + detail=_context_detail( + ctx, + hold_credits=policy.hold_credits, + config_key=policy.config_key, + config_source=policy.source_key, + ), + error=policy.error, + ) + raise LlmBillingConfigurationError(policy.error or "LLM计费配置无效") + + amount = policy.hold_credits + ctx.hold_credits = amount + _log( + ctx, + LlmBillingEvent.HOLD_START, + status="started", + detail=_context_detail( + ctx, + hold_credits=amount, + config_key=policy.config_key, + config_source=policy.source_key, + ), + ) + try: + mutation = await deduct_credits_result( + db, + user_id=ctx.user_id, + amount=amount, + description=f"{ctx.description_prefix}预扣积分", + related_id=ctx.related_id or ctx.owner_id, + biz_key=ctx.hold_biz_key, + record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD.value), + allow_negative=False, + ) + except InsufficientCreditsError: + _log( + ctx, + LlmBillingEvent.HOLD_INSUFFICIENT, + status="failed", + detail=_context_detail(ctx, hold_credits=amount), + error="积分不足,无法预扣", + ) + raise + + if not mutation.created: + # 并发幂等命中后重新读取三类流水,避免复用已被另一事务释放的 HOLD。 + ledger = await _load_ledger(db, ctx) + if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None: + error = f"并发预扣后账务状态为{ledger.state.value},拒绝继续执行" + _log( + ctx, + LlmBillingEvent.EXECUTION_BLOCKED, + status="failed", + detail=_context_detail(ctx, ledger_state=ledger.state.value), + error=error, + ) + raise LlmBillingStateError(error) + mutation_record = ledger.hold + amount = ledger.hold_amount + else: + mutation_record = mutation.record + + _log( + ctx, + LlmBillingEvent.HOLD_SUCCESS, + detail=_context_detail( + ctx, + hold_credits=amount, + hold_record_id=mutation_record.id if mutation_record else None, + idempotent=not mutation.created, + balance_before=mutation.balance_before, + balance_after=mutation.balance_after, + ledger_state=LlmBillingLedgerState.ACTIVE.value, + ), + ) + return LlmHoldResult( + amount, + LlmBillingLedgerState.ACTIVE, + created=mutation.created, + record_id=mutation_record.id if mutation_record else None, + ) + + +async def ensure_hold_exists(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldValidation: + """worker 调用模型前确认计费绕过或 active HOLD;不在 worker 首次预扣。""" + _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started") + ledger = await _load_ledger(db, ctx) + + # 先尊重已经落库的 attempt 账务状态,再处理当前配置。这样关闭计费不会 + # 把运行中的 active HOLD 遗留为永久冻结;而新建且没有 HOLD 的任务才会绕过。 + if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: + amount = ledger.hold_amount + ctx.hold_credits = amount + result = LlmHoldValidation(True, amount, ledger.state, hold_record_id=ledger.hold.id) + _log( + ctx, + LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, + detail=_context_detail( + ctx, + hold_credits=amount, + hold_record_id=ledger.hold.id, + ledger_state=ledger.state.value, + ), + ) + return result + + if ledger.state == LlmBillingLedgerState.MISSING: + policy = await get_llm_billing_policy( + db, + config_key=ctx.hold_config_key, + explicit_hold_credits=None, + ) + else: + policy = None + if policy is not None and policy.bypassed: + result = LlmHoldValidation( + True, + 0.0, + LlmBillingLedgerState.BILLING_BYPASSED, + "billing_disabled", + ) + _log( + ctx, + LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, + detail=_context_detail( + ctx, + ledger_state=result.state.value, + billing_bypassed=True, + ), + ) + return result + + if policy is not None and not policy.valid: + result = LlmHoldValidation(False, 0.0, LlmBillingLedgerState.INVALID, "billing_config_invalid") + _log( + ctx, + LlmBillingEvent.EXECUTION_BLOCKED, + status="failed", + detail=_context_detail(ctx, ledger_state=result.state.value), + error=policy.error, + ) + return result + + result = LlmHoldValidation( + False, + ledger.hold_amount, + ledger.state, + ledger.reason or f"ledger_{ledger.state.value}", + ledger.hold.id if ledger.hold else None, + ) + event = ( + LlmBillingEvent.HOLD_MISSING + if ledger.state == LlmBillingLedgerState.MISSING + else LlmBillingEvent.EXECUTION_BLOCKED + ) + _log( + ctx, + event, + status="failed", + detail=_context_detail( + ctx, + hold_credits=result.amount, + hold_record_id=result.hold_record_id, + ledger_state=result.state.value, + skip_reason=result.reason, + ), + error="LLM预扣不是有效冻结状态,拒绝调用模型", + ) + return result + + +async def _release_active_hold( + db: AsyncSession, + ctx: LlmBillingContext, + *, + hold_record: CreditRecord, + reason: str, +) -> BillingItem: + amount = _round2(abs(float(hold_record.amount or 0))) + ctx.hold_credits = amount + if amount <= 0: + _log(ctx, LlmBillingEvent.HOLD_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_record_id=hold_record.id, reason=reason, skip_reason="hold_amount_not_positive")) + return BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=0.0, + charged=False, + skipped_reason="hold_amount_not_positive", + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + + _log(ctx, LlmBillingEvent.HOLD_RELEASE_START, status="started", detail=_context_detail(ctx, hold_credits=amount, hold_record_id=hold_record.id, reason=reason)) + mutation = await add_credits_result( + db, + user_id=ctx.user_id, + amount=amount, + description=f"{ctx.description_prefix}预扣积分释放", + related_id=ctx.related_id or ctx.owner_id, + record_type="refund", + biz_key=ctx.hold_release_biz_key, + refund_for_biz_key=ctx.hold_biz_key, + record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD_RELEASE.value), + ) + _log( + ctx, + LlmBillingEvent.HOLD_RELEASE_SUCCESS, + detail=_context_detail( + ctx, + hold_credits=amount, + hold_record_id=hold_record.id, + hold_release_record_id=mutation.record.id if mutation.record else None, + reason=reason, + idempotent=not mutation.created, + balance_before=mutation.balance_before, + balance_after=mutation.balance_after, + ), + ) + return BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=amount, + charged=False, + skipped_reason=None if mutation.created else "already_released", + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + + +async def release_hold(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem: + ledger = await _load_ledger(db, ctx) + + # 即使管理员已经关闭计费,历史 active HOLD 也必须按真实冻结流水释放。 + if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold: + return await _release_active_hold(db, ctx, hold_record=ledger.hold, reason=reason) + if ledger.state in (LlmBillingLedgerState.RELEASED, LlmBillingLedgerState.CHARGED): + amount = _round2(abs(float(ledger.release.amount or 0))) if ledger.release else ledger.hold_amount + _log( + ctx, + LlmBillingEvent.HOLD_RELEASE_SKIPPED, + status="skipped", + detail=_context_detail( + ctx, + reason=reason, + hold_credits=amount, + ledger_state=ledger.state.value, + skip_reason="already_released", + idempotent=True, + ), + ) + return BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=amount, + charged=False, + skipped_reason="already_released", + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + if ledger.state == LlmBillingLedgerState.MISSING: + policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key) + else: + policy = None + if policy is not None and policy.bypassed: + _log( + ctx, + LlmBillingEvent.HOLD_RELEASE_SKIPPED, + status="skipped", + detail=_context_detail( + ctx, + reason=reason, + ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value, + skip_reason="billing_disabled", + ), + ) + return BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=0.0, + charged=False, + skipped_reason="billing_disabled", + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + + _log( + ctx, + LlmBillingEvent.HOLD_RELEASE_SKIPPED, + status="skipped", + detail=_context_detail( + ctx, + reason=reason, + ledger_state=ledger.state.value, + skip_reason=ledger.reason or ledger.state.value, + ), + error="没有可释放的有效LLM预扣流水", + ) + return BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=0.0, + charged=False, + skipped_reason=ledger.reason or ledger.state.value, + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + + +async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary: + _log(ctx, LlmBillingEvent.FAILURE_RELEASE_START, status="started", detail=_context_detail(ctx, error=error), error=error) + item = await release_hold(db, ctx, reason="failure") + if item.amount > 0 and not item.skipped_reason: + _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SUCCESS, detail=_context_detail(ctx, hold_credits=item.amount, error=error), error=error) + else: + _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_credits=item.amount, error=error, skip_reason=item.skipped_reason or "hold_not_active"), error=error) + return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item]) + + +async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta: + usage_snapshot = dict(usage or {}) + ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider + ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name + ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id + if ctx.owner_type == CreditRecordOwnerType.GENERATION_RECORD.value: + return await build_generation_record_prompt_meta( + db, + record_id=ctx.owner_id, + attempt_no=ctx.attempt_no, + charge_kind=ctx.charge_kind, + usage=usage_snapshot, + ) + if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value: + return await build_module_step_prompt_meta( + db, + step_id=ctx.owner_id, + attempt_no=ctx.attempt_no, + usage=usage_snapshot, + ) + if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value: + if not usage_snapshot.get("token_usage_id"): + input_tokens = _safe_int(usage_snapshot.get("input_tokens")) + output_tokens = _safe_int(usage_snapshot.get("output_tokens")) + token_usage = TokenUsage( + id=generate_id(), + model_config_id=usage_snapshot.get("model_config_id"), + user_id=ctx.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=ctx.owner_type, + owner_id=ctx.owner_id, + biz_key=ctx.charge_biz_key, + source_module=ctx.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value, + source_step_code=ctx.source_step_code, + ) + db.add(token_usage) + await db.flush() + usage_snapshot["token_usage_id"] = token_usage.id + ctx.token_usage_id = token_usage.id + return await build_shot_video_analysis_meta( + db, + owner_type=ctx.owner_type, + owner_id=ctx.owner_id, + attempt_no=ctx.attempt_no, + usage=usage_snapshot, + billing_scene=ctx.billing_scene or CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value, + source_project_id=ctx.source_project_id, + source_step_id=ctx.source_step_id, + ) + return CreditRecordMeta( + owner_type=ctx.owner_type, + owner_id=ctx.owner_id, + attempt_no=ctx.attempt_no, + charge_kind=ctx.charge_kind, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.TEXT.value, + billing_scene=ctx.billing_scene, + source_module=ctx.source_module, + source_project_id=ctx.source_project_id, + source_step_id=ctx.source_step_id, + source_step_code=ctx.source_step_code, + token_usage_id=usage_snapshot.get("token_usage_id"), + input_tokens=_safe_int(usage_snapshot.get("input_tokens")), + output_tokens=_safe_int(usage_snapshot.get("output_tokens")), + total_tokens=_safe_int(usage_snapshot.get("total_tokens")), + ) + + +async def _settle_success_impl( + db: AsyncSession, + ctx: LlmBillingContext, + *, + usage: Mapping[str, Any], + description: str | None = None, +) -> BillingSummary: + ledger = await _load_ledger(db, ctx) + + if ledger.state == LlmBillingLedgerState.CHARGED and ledger.hold and ledger.release and ledger.charge: + release_item = BillingItem( + charge_key=CreditRecordAction.HOLD_RELEASE.value, + amount=abs(_round2(ledger.release.amount)), + charged=False, + skipped_reason="already_released", + biz_key=ctx.hold_release_biz_key, + attempt_no=ctx.attempt_no, + ) + charge_item = BillingItem( + charge_key=ctx.charge_kind, + amount=abs(_round2(ledger.charge.amount)), + charged=False, + skipped_reason="already_charged", + biz_key=ctx.charge_biz_key, + attempt_no=ctx.attempt_no, + ) + _log( + ctx, + LlmBillingEvent.SETTLE_SUCCESS, + detail=_context_detail( + ctx, + actual_credits=charge_item.amount, + ledger_state=ledger.state.value, + idempotent=True, + ), + ) + return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item]) + + # 新任务在关闭计费时没有 HOLD,成功后直接绕过;历史 active HOLD 则必须继续结算。 + if ledger.state == LlmBillingLedgerState.MISSING: + policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key) + else: + policy = None + if policy is not None and policy.bypassed: + _log( + ctx, + LlmBillingEvent.SETTLE_SUCCESS, + status="skipped", + detail=_context_detail( + ctx, + ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value, + billing_bypassed=True, + ), + ) + return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[]) + + if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None: + # 统一由 settle_success 外层记录一次 SETTLE_FAILED,避免同一异常产生重复日志。 + raise LlmBillingStateError( + f"当前attempt账务状态为{ledger.state.value},不能执行成功结算" + ) + + _log(ctx, LlmBillingEvent.SETTLE_START, status="started", detail=_context_detail(ctx, ledger_state=ledger.state.value, usage=dict(usage or {}))) + release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success") + input_tokens = _safe_int((usage or {}).get("input_tokens")) + output_tokens = _safe_int((usage or {}).get("output_tokens")) + amount = await calc_text_credits(db, input_tokens, output_tokens) + meta = await _build_charge_meta(db, ctx, usage) + if meta.charge_action is None: + meta.charge_action = CreditRecordAction.CHARGE.value + meta.billing_scene = meta.billing_scene or ctx.billing_scene + meta.source_module = meta.source_module or ctx.source_module + meta.source_project_id = meta.source_project_id or ctx.source_project_id + meta.source_step_id = meta.source_step_id or ctx.source_step_id + meta.source_step_code = meta.source_step_code or ctx.source_step_code + + mutation = await deduct_credits_result( + db, + user_id=ctx.user_id, + amount=amount, + description=description or f"{ctx.description_prefix}真实扣费", + related_id=ctx.related_id or ctx.owner_id, + biz_key=ctx.charge_biz_key, + record_meta=meta, + allow_negative=True, + create_zero_record=True, + ) + charged_amount = mutation.amount + charge_item = BillingItem( + charge_key=ctx.charge_kind, + amount=charged_amount, + charged=mutation.created, + skipped_reason=None if mutation.created else "already_charged", + biz_key=ctx.charge_biz_key, + attempt_no=ctx.attempt_no, + ) + + if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value: + result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == ctx.owner_id).limit(1)) + step = result.scalar_one_or_none() + if step: + step.token_usage_id = meta.token_usage_id + step.model_config_id = (usage or {}).get("model_config_id") + step.input_tokens = meta.input_tokens + step.output_tokens = meta.output_tokens + step.total_tokens = meta.total_tokens + step.text_credits_cost = charged_amount + + after_balance = mutation.balance_after + _log( + ctx, + LlmBillingEvent.CHARGE_SUCCESS, + detail=_context_detail( + ctx, + actual_credits=charged_amount, + charge_record_id=mutation.record.id if mutation.record else None, + idempotent=not mutation.created, + balance_before=mutation.balance_before, + balance_after=after_balance, + allow_negative=True, + ), + ) + if after_balance < 0: + _log(ctx, LlmBillingEvent.CHARGE_NEGATIVE_BALANCE, status="warning", detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, allow_negative=True)) + _log(ctx, LlmBillingEvent.SETTLE_SUCCESS, detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, ledger_state=LlmBillingLedgerState.CHARGED.value, idempotent=not mutation.created)) + return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item]) + +async def settle_success( + db: AsyncSession, + ctx: LlmBillingContext, + *, + usage: Mapping[str, Any], + description: str | None = None, +) -> BillingSummary: + """成功结算统一入口;任何异常都留下可检索的 SETTLE_FAILED 日志。""" + try: + return await _settle_success_impl( + db, + ctx, + usage=usage, + description=description, + ) + except Exception as exc: + _log( + ctx, + LlmBillingEvent.SETTLE_FAILED, + status="failed", + detail=_context_detail(ctx, error_type=type(exc).__name__), + error=str(exc), + ) + raise diff --git a/video-gen-api/app/services/module_async_recovery_service.py b/video-gen-api/app/services/module_async_recovery_service.py index 21a67f39..6e351e50 100644 --- a/video-gen-api/app/services/module_async_recovery_service.py +++ b/video-gen-api/app/services/module_async_recovery_service.py @@ -11,9 +11,15 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.enums.common import ModuleStepStatusEnum +from app.enums.common import ModuleProjectStatusEnum, ModuleStepStatusEnum from app.enums.celery_queue import CeleryQueue from app.enums.celery_runtime import CeleryRuntimeDomain +from app.enums.credit_record import ( + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, +) +from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum from app.enums.shot_replicate import ( ModuleCodeEnum as ShotModuleCodeEnum, @@ -32,6 +38,8 @@ from app.services.redis_registry_service import ( utc_now, ) from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values +from app.services.llm_billing import LlmBillingContext, LlmHoldValidation, get_llm_ledger_states +from app.services.llm_billing.config import get_llm_billing_policy from app.tasks.celery_app import celery_app logger = logging.getLogger("video_gen") @@ -55,6 +63,87 @@ TERMINAL_STEP_STATUSES = { ModuleStepStatusEnum.CANCELLED.value, } + +def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext: + is_image_prompt = step.step_code in { + HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + } + if step.module == HOT_MODULE: + billing_scene = ( + CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value + if is_image_prompt + else CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value + ) + else: + billing_scene = ( + CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value + if is_image_prompt + else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value + ) + return LlmBillingContext( + user_id=str(step.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=max(1, int(step.version or 1)), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=billing_scene, + source_module=str(step.module), + source_project_id=str(step.project_id), + source_step_id=str(step.id), + source_step_code=str(step.step_code), + related_id=str(step.id), + hold_config_key=( + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value + if is_image_prompt + else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value + ), + description_prefix="模块AI提词优化", + trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}", + ) + + +async def _load_step_billing_validations( + db: AsyncSession, + steps: Iterable[ModuleGenerationStep], +) -> dict[str, LlmHoldValidation]: + step_list = list(steps) + if not step_list: + return {} + contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list} + policies = {} + for config_key in {ctx.hold_config_key for ctx in contexts.values() if ctx.hold_config_key}: + policies[config_key] = await get_llm_billing_policy(db, config_key=config_key) + # 无论当前配置是否关闭,都批量读取历史 attempt 流水:运行中的 active HOLD + # 必须继续结算,不能因后台关闭计费而被当成 bypass 遗留冻结。 + ledger_states = await get_llm_ledger_states(db, contexts.values()) + output: dict[str, LlmHoldValidation] = {} + for step_id, ctx in contexts.items(): + policy = policies.get(ctx.hold_config_key) + ledger = ledger_states.get( + ctx.hold_biz_key, + LlmHoldValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), + ) + if ledger.state == LlmBillingLedgerState.ACTIVE: + output[step_id] = ledger + elif ledger.state == LlmBillingLedgerState.MISSING and policy is not None and policy.bypassed: + output[step_id] = LlmHoldValidation( + True, + 0.0, + LlmBillingLedgerState.BILLING_BYPASSED, + "billing_disabled", + ) + elif ledger.state == LlmBillingLedgerState.MISSING and (policy is None or not policy.valid): + output[step_id] = LlmHoldValidation( + False, + 0.0, + LlmBillingLedgerState.INVALID, + "billing_config_invalid", + ) + else: + output[step_id] = ledger + return output + def _now() -> datetime: return datetime.now(timezone.utc) @@ -296,6 +385,13 @@ async def acquire_object_lock(*, object_type: str, object_id: str) -> str | None return token +async def has_live_object_lock(*, object_type: str, object_id: str) -> bool: + """判断对象是否已被 worker 领取,供投递异常补偿规避不确定投递竞态。""" + lock_key = _lock_key(object_type, object_id) + values = await runtime_lock_values([lock_key]) + return bool(values.get(lock_key)) + + async def ensure_object_lock_owned(*, token: str | None) -> None: if not token: raise RuntimeError("module async execution token is missing") @@ -344,19 +440,36 @@ def _payload_args(payload: dict[str, Any]) -> list[Any]: return [] -def _send_task(task_name: str, *, args: list[Any], queue: str, countdown: int = 0, priority: int | None = None) -> bool: +def _module_task_id(task_name: str, args: list[Any]) -> str | None: + step_id = str(args[-1]) if args else "" + if not step_id: + return None + if task_name == TASK_HOT_IMAGE_PROMPT: + return f"hot-opening:image-prompt:{step_id}" + if task_name == TASK_HOT_VIDEO_PROMPT: + return f"hot-opening:video-prompt:{step_id}" + if task_name == TASK_SHOT_IMAGE_PROMPT: + return f"shot-replicate:image-prompt:{step_id}" + if task_name == TASK_SHOT_VIDEO_PROMPT: + return f"shot-replicate:video-prompt:{step_id}" + if task_name == TASK_MODULE_V2_VIDEO_PROMPT: + return f"module-v2-video-prompt:{step_id}" + return f"module-async:{task_name}:{step_id}" + + +def _send_task(task_name: str, *, args: list[Any], queue: str, countdown: int = 0, priority: int | None = None) -> None: if celery_app is None: - return False + raise RuntimeError("Celery 未启用,不能恢复投递模块 LLM 任务") if not task_name or not queue: - return False + raise ValueError("恢复投递缺少 task_name 或 queue") celery_app.send_task( task_name, args=args, queue=queue, countdown=max(0, int(countdown or 0)), priority=priority if priority is not None else settings.DOWNLOAD_TASK_PRIORITY_RECOVER, + task_id=_module_task_id(task_name, args), ) - return True async def _recover_payload_from_redis(db: AsyncSession, item_id: str, payload: dict[str, Any]) -> str: @@ -424,11 +537,19 @@ async def _recover_due_redis_items(db: AsyncSession, *, limit: int) -> dict[str, step_ids.add(object_id) step_map: dict[str, ModuleGenerationStep] = {} + project_map: dict[str, ModuleGenerationProject] = {} if step_ids: step_result = await db.execute( select(ModuleGenerationStep).where(ModuleGenerationStep.id.in_(step_ids)) ) step_map = {str(step.id): step for step in step_result.scalars().all()} + project_ids = {str(step.project_id) for step in step_map.values()} + if project_ids: + project_result = await db.execute( + select(ModuleGenerationProject).where(ModuleGenerationProject.id.in_(project_ids)) + ) + project_map = {str(project.id): project for project in project_result.scalars().all()} + billing_validations = await _load_step_billing_validations(db, step_map.values()) lock_keys = [_lock_key(OBJECT_MODULE_STEP, step_id) for step_id in step_ids] lock_values = await runtime_lock_values(lock_keys) @@ -439,6 +560,20 @@ async def _recover_due_redis_items(db: AsyncSession, *, limit: int) -> dict[str, await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=object_id) results["remove_terminal"] = results.get("remove_terminal", 0) + 1 continue + billing_validation = billing_validations.get(object_id) + if billing_validation is None or not billing_validation.can_execute: + state = billing_validation.state.value if billing_validation else LlmBillingLedgerState.MISSING.value + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({state}),恢复任务已终止" + step.completed_at = _now() + project = project_map.get(str(step.project_id)) + if project is not None: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await remove_active_task(object_type=OBJECT_MODULE_STEP, object_id=object_id) + result_key = f"remove_billing_{state}" + results[result_key] = results.get(result_key, 0) + 1 + continue if lock_values.get(_lock_key(OBJECT_MODULE_STEP, object_id)): await postpone_active_task( object_type=OBJECT_MODULE_STEP, @@ -479,18 +614,21 @@ async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[s ) steps = list(result.scalars().all()) project_flow_map: dict[str, str] = {} + project_map: dict[str, ModuleGenerationProject] = {} project_ids = list({step.project_id for step in steps}) if project_ids: project_result = await db.execute( - select(ModuleGenerationProject.id, ModuleGenerationProject.flow_version).where( + select(ModuleGenerationProject).where( ModuleGenerationProject.id.in_(project_ids), ModuleGenerationProject.deleted_at.is_(None), ) ) + project_map = {str(project.id): project for project in project_result.scalars().all()} project_flow_map = { - str(project_id): str(flow_version or "v1") - for project_id, flow_version in project_result.all() + project_id: str(project.flow_version or "v1") + for project_id, project in project_map.items() } + billing_validations = await _load_step_billing_validations(db, steps) results: dict[str, int] = {} dispatches: list[tuple[str, str, str, str, str]] = [] lock_keys = [_lock_key(OBJECT_MODULE_STEP, str(step.id)) for step in steps] @@ -499,6 +637,19 @@ async def _recover_stale_module_steps(db: AsyncSession, *, limit: int) -> dict[s if live_locks.get(_lock_key(OBJECT_MODULE_STEP, str(step.id))): results["skip_live_step_lock"] = results.get("skip_live_step_lock", 0) + 1 continue + billing_validation = billing_validations.get(str(step.id)) + if billing_validation is None or not billing_validation.can_execute: + state = billing_validation.state.value if billing_validation else LlmBillingLedgerState.MISSING.value + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({state}),恢复任务已终止" + step.completed_at = _now() + project = project_map.get(str(step.project_id)) + if project is not None: + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + result_key = f"db_step_billing_{state}" + results[result_key] = results.get(result_key, 0) + 1 + continue if project_flow_map.get(step.project_id, "v1") == "v2" and step.step_code == HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value: task_name = TASK_MODULE_V2_VIDEO_PROMPT elif step.module == HOT_MODULE and step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value: diff --git a/video-gen-api/app/services/module_generation_v2/dispatch_service.py b/video-gen-api/app/services/module_generation_v2/dispatch_service.py index 50bbebfd..8a5463ea 100644 --- a/video-gen-api/app/services/module_generation_v2/dispatch_service.py +++ b/video-gen-api/app/services/module_generation_v2/dispatch_service.py @@ -11,6 +11,12 @@ from app.services.module_async_recovery_service import ( register_module_step_task, ) from app.services.module_generation_log_service import log_module_error, log_module_event_file +from app.services.llm_billing import ( + LlmBillingContext, + log_celery_dispatch_failure, + log_celery_dispatch_start, + log_celery_dispatch_success, +) from app.services.module_generation_v2.config import VIDEO_PROMPT_OPTIMIZE, ModuleGenerationV2Config from app.tasks.celery_app import celery_app from app.tasks.module_generation_v2_tasks import start_video_prompt_optimize_v2 @@ -38,12 +44,14 @@ async def dispatch_video_prompt_v2( config: ModuleGenerationV2Config, project_id: str, step_id: str, + billing_context: LlmBillingContext, ) -> VideoPromptDispatchResult: """注册并投递 V2 视频提词任务。 Redis 注册成功但 Celery 直投失败时,由周期恢复任务补投;Celery 成功但 Redis 注册失败时任务仍可正常执行。只有两个通道都失败时由 API 补偿落库为失败。 """ + log_celery_dispatch_start(billing_context) registry_error: Exception | None = None try: await register_module_step_task( @@ -91,6 +99,7 @@ async def dispatch_video_prompt_v2( celery_error=str(celery_error) if celery_error else None, ) if result.celery_success: + log_celery_dispatch_success(billing_context) log_module_event_file( module=config.module, event_type=ModuleEventTypeEnum.V2_VIDEO_PROMPT_DISPATCHED.value, @@ -102,4 +111,9 @@ async def dispatch_video_prompt_v2( "redis_registry_available": result.registry_success, }, ) + if not result.celery_success: + log_celery_dispatch_failure( + billing_context, + error=result.celery_error or "Celery direct dispatch failed; waiting for registry recovery", + ) return result 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 361c321a..ed5d7d86 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 @@ -17,7 +17,9 @@ from app.enums.common import ( ModuleProjectStatusEnum, ModuleStepStatusEnum, ) +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType from app.enums.generation_task import ChatGenerationTaskStatus +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.shot_replicate import ( ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum, @@ -42,12 +44,21 @@ from app.services.generation.ai.engine_service import ( get_video_engine, parse_json_list, ) -from app.services.generation.billing_service import charge_module_prompt_usage from app.services.generation.pipeline.db_lock_service import ( DatabaseRowLockBusy, execute_with_lock_timeout, ) from app.services.generation.task_factory_service import create_chat_generation_task_for_module +from app.services.llm_billing import ( + LlmBillingContext, + ensure_hold_exists, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, + start_hold, +) from app.services.hot_opening_video_prompt_service import ( build_final_video_prompt, optimize_hot_opening_video_prompt, @@ -331,6 +342,39 @@ async def _create_material_and_prompt_steps( return material_step, prompt_step + + +def build_v2_video_prompt_billing_context( + *, + user_id: str, + project_id: str, + step_id: str, + step_version: int, + module: str, + display_name: str, +) -> LlmBillingContext: + return LlmBillingContext( + user_id=str(user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step_id), + attempt_no=int(step_version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value + if module == "hot_opening_replicate" + else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=str(module), + source_project_id=str(project_id), + source_step_id=str(step_id), + source_step_code=VIDEO_PROMPT_OPTIMIZE, + related_id=str(step_id), + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix=f"{display_name}视频提词优化", + trace_id=f"module-v2-video-prompt:{step_id}", + ) + + async def create_hot_opening_project_v2( db: AsyncSession, *, @@ -389,6 +433,17 @@ async def create_hot_opening_project_v2( video_config=video_config, target_platform=req.target_platform or "抖音", ) + await start_hold( + db, + build_v2_video_prompt_billing_context( + user_id=str(current_user.id), + project_id=str(project.id), + step_id=str(prompt_step.id), + step_version=int(prompt_step.version or 1), + module=HOT_OPENING_V2.module, + display_name=HOT_OPENING_V2.display_name, + ), + ) await bind_upload_resources( db, user_id=current_user.id, @@ -545,6 +600,17 @@ async def create_shot_replicate_project_v2( urls=[req.material_image_url], allow_common_migrate=True, ) + await start_hold( + db, + build_v2_video_prompt_billing_context( + user_id=str(current_user.id), + project_id=str(project.id), + step_id=str(prompt_step.id), + step_version=int(prompt_step.version or 1), + module=SHOT_REPLICATE_V2.module, + display_name=SHOT_REPLICATE_V2.display_name, + ), + ) segment.module_project_id = project.id segment.replicate_status = ShotSegmentReplicateStatusEnum.PROJECT_CREATED.value await log_v2_event( @@ -658,6 +724,17 @@ async def rebuild_video_prompt_step_v2( project.final_video_cover_url = None project.completed_at = None project.error_message = None + await start_hold( + db, + build_v2_video_prompt_billing_context( + user_id=str(project.user_id), + project_id=str(project.id), + step_id=str(step.id), + step_version=int(step.version or 1), + module=config.module, + display_name=config.display_name, + ), + ) await log_v2_event( db, project=project, @@ -722,6 +799,18 @@ async def mark_video_prompt_dispatch_failed_v2( message=error_message, detail={"dispatch_compensated": True}, ) + await release_on_failure( + db, + build_v2_video_prompt_billing_context( + user_id=str(project.user_id), + project_id=str(project.id), + step_id=str(step.id), + step_version=int(step.version or 1), + module=config.module, + display_name=config.display_name, + ), + error=error_message, + ) await db.commit() @@ -783,8 +872,41 @@ async def run_video_prompt_optimize_v2( if not project_snapshot["material_video_url"]: raise RuntimeError("V2 素材步骤缺少参考视频") schema_config_snapshot = await get_runtime_schema_snapshot(db) + llm_billing_context = LlmBillingContext( + user_id=project_snapshot["user_id"], + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=project_snapshot["step_id"], + attempt_no=expected_version, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value + if project_snapshot["module"] == "hot_opening_replicate" + else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=project_snapshot["module"], + source_project_id=project_snapshot["project_id"], + source_step_id=project_snapshot["step_id"], + source_step_code=VIDEO_PROMPT_OPTIMIZE, + related_id=project_snapshot["step_id"], + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix=f"{config.display_name}视频提词优化", + trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.completed_at = utc_now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await log_v2_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=step.error_message) + await db.commit() + return step await db.commit() + provider_succeeded = False + usage: dict[str, Any] = {} + log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"}) prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt( db, user_id=project_snapshot["user_id"], @@ -800,6 +922,8 @@ async def run_video_prompt_optimize_v2( project_id=project_snapshot["project_id"], step_id=project_snapshot["step_id"], ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=usage) if execution_guard is not None: await execution_guard() @@ -821,10 +945,24 @@ async def run_video_prompt_optimize_v2( row = locked.first() if not row: await db.rollback() + # Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。 + await settle_success( + db, + llm_billing_context, + usage=usage, + description=f"{config.display_name}-视频提词优化(业务对象失效结算)", + ) + await db.commit() return None project, step = row if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value: - await db.rollback() + await settle_success( + db, + llm_billing_context, + usage=usage, + description=f"{config.display_name}-视频提词优化(失效结果结算)", + ) + await db.commit() log_module_event_file( module=project_snapshot["module"], event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value, @@ -835,13 +973,11 @@ async def run_video_prompt_optimize_v2( detail={"expected_version": expected_version}, ) return None - billing = await charge_module_prompt_usage( + billing = await settle_success( db, - user_id=project.user_id, - step_id=step.id, + llm_billing_context, usage=usage, description=f"{config.display_name}-视频提词优化", - attempt_no=1, ) output_payload = { "prompt_schema": prompt_schema, @@ -861,7 +997,7 @@ async def run_video_prompt_optimize_v2( payload=output_payload, usage={ **dict(usage or {}), - "text_credits_cost": round(billing.total_charged, 2), + "text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value), }, schema_version=config.io_schema_version, ), @@ -884,10 +1020,31 @@ async def run_video_prompt_optimize_v2( return step except DatabaseRowLockBusy: await db.rollback() + if locals().get("provider_succeeded", False): + await settle_success( + db, + llm_billing_context, + usage=locals().get("usage") or {}, + description=f"{config.display_name}-视频提词优化(行锁失败结算)", + ) + await db.commit() + return None raise except Exception as exc: await db.rollback() + if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): + log_provider_failure(llm_billing_context, error=str(exc)) try: + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False): + await settle_success( + db, + llm_billing_context, + usage=locals().get("usage") or {}, + description=f"{config.display_name}-视频提词优化(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() result = await execute_with_lock_timeout( @@ -913,7 +1070,9 @@ async def run_video_prompt_optimize_v2( step.completed_at = utc_now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = str(exc) - await db.commit() + # provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE; + # 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。 + await db.commit() log_module_error( module=row[0].module if row else "module_generation_v2", event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, @@ -930,9 +1089,11 @@ async def run_video_prompt_optimize_v2( project_id=project_id, step_id=step_id, message="V2 视频提词失败状态落库失败", - detail={"origin_error": str(exc)}, + detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)}, exc=mark_exc, ) + if locals().get("provider_succeeded", False): + raise return None diff --git a/video-gen-api/app/services/operation_log_service.py b/video-gen-api/app/services/operation_log_service.py index 1b013b5f..5b6801a9 100644 --- a/video-gen-api/app/services/operation_log_service.py +++ b/video-gen-api/app/services/operation_log_service.py @@ -1,9 +1,12 @@ from __future__ import annotations +import atexit import json import logging import os +import queue import re +import threading import traceback from datetime import datetime from typing import Any @@ -51,6 +54,30 @@ FILE_BASE64_KEYS = { FILE_DATA_URI_MIME_PREFIXES = ("image/", "video/", "audio/") FILE_DATA_URI_MIME_TYPES = {"application/pdf", "application/octet-stream"} FILE_BASE64_PREVIEW_CHARS = 30 +LOG_WRITE_QUEUE_SIZE = 10000 + +_LOG_WRITE_QUEUE: queue.Queue[tuple[str, str] | None] = queue.Queue(maxsize=LOG_WRITE_QUEUE_SIZE) +_LOG_WRITER_THREAD: threading.Thread | None = None +_LOG_WRITER_PID = os.getpid() +_LOG_WRITER_START_LOCK = threading.Lock() +_LOG_DIRECTORY_LOCK = threading.Lock() +_CREATED_LOG_DIRECTORIES: set[str] = set() + + +def _reset_log_writer_after_fork() -> None: + """Celery prefork 子进程不得复用父进程的线程、Queue 或锁。""" + global _LOG_WRITE_QUEUE, _LOG_WRITER_THREAD, _LOG_WRITER_PID + global _LOG_WRITER_START_LOCK, _LOG_DIRECTORY_LOCK, _CREATED_LOG_DIRECTORIES + _LOG_WRITE_QUEUE = queue.Queue(maxsize=LOG_WRITE_QUEUE_SIZE) + _LOG_WRITER_THREAD = None + _LOG_WRITER_PID = os.getpid() + _LOG_WRITER_START_LOCK = threading.Lock() + _LOG_DIRECTORY_LOCK = threading.Lock() + _CREATED_LOG_DIRECTORIES = set() + + +if hasattr(os, "register_at_fork"): + os.register_at_fork(after_in_child=_reset_log_writer_after_fork) def _safe_name(value: str | None, default: str = "unknown") -> str: @@ -159,17 +186,93 @@ def build_exception_detail(exc: BaseException | None, extra: dict[str, Any] | No return detail +def _ensure_log_directory(path: str) -> None: + if path in _CREATED_LOG_DIRECTORIES: + return + with _LOG_DIRECTORY_LOCK: + if path not in _CREATED_LOG_DIRECTORIES: + os.makedirs(path, exist_ok=True) + _CREATED_LOG_DIRECTORIES.add(path) + + +def _write_log_line(path: str, line: str) -> None: + target_dir = os.path.dirname(path) + _ensure_log_directory(target_dir) + with open(path, "a", encoding="utf-8") as file_obj: + file_obj.write(line) + + +def _log_writer_loop() -> None: + while True: + item = _LOG_WRITE_QUEUE.get() + try: + if item is None: + return + path, line = item + _write_log_line(path, line) + except Exception as exc: + logger.warning("operation log async write failed: error=%s", exc, exc_info=True) + finally: + _LOG_WRITE_QUEUE.task_done() + + +def _ensure_log_writer() -> None: + global _LOG_WRITER_THREAD + if _LOG_WRITER_PID != os.getpid(): + # 非 POSIX/spawn 或 register_at_fork 不可用时的兜底。 + _reset_log_writer_after_fork() + thread = _LOG_WRITER_THREAD + if thread is not None and thread.is_alive(): + return + with _LOG_WRITER_START_LOCK: + thread = _LOG_WRITER_THREAD + if thread is None or not thread.is_alive(): + thread = threading.Thread( + target=_log_writer_loop, + name="operation-log-writer", + daemon=True, + ) + thread.start() + _LOG_WRITER_THREAD = thread + + +def _flush_pending_logs() -> None: + """进程正常退出时尽力同步落盘尚未消费的日志。""" + while True: + try: + item = _LOG_WRITE_QUEUE.get_nowait() + except queue.Empty: + return + try: + if item is not None: + _write_log_line(*item) + except Exception as exc: + logger.warning("operation log shutdown flush failed: error=%s", exc, exc_info=True) + finally: + _LOG_WRITE_QUEUE.task_done() + + +atexit.register(_flush_pending_logs) + + def _append_json_log(root_dir: str, domain: str | None, entry: dict[str, Any]) -> None: if not is_enabled(): return + target_dir = root_dir if domain is None else os.path.join(root_dir, _safe_name(domain, "default")) + today = datetime.now().strftime(LOG_DATE_FORMAT) + path = os.path.join(target_dir, f"{today}.log") try: - target_dir = root_dir if domain is None else os.path.join(root_dir, _safe_name(domain, "default")) - os.makedirs(target_dir, exist_ok=True) - today = datetime.now().strftime(LOG_DATE_FORMAT) - with open(os.path.join(target_dir, f"{today}.log"), "a", encoding="utf-8") as f: - f.write(json.dumps(sanitize_log_value(entry), ensure_ascii=False, default=str) + "\n") + line = json.dumps(sanitize_log_value(entry), ensure_ascii=False, default=str) + "\n" + _ensure_log_writer() + _LOG_WRITE_QUEUE.put_nowait((path, line)) + except queue.Full: + # 队列满时同步降级,账务/异常日志不能静默丢失。 + try: + _write_log_line(path, line) + except Exception as exc: + logger.warning("operation log fallback write failed: path=%s error=%s", path, exc, exc_info=True) except Exception as exc: - logger.warning("operation log write failed: root_dir=%s domain=%s error=%s", root_dir, domain, exc, exc_info=True) + logger.warning("operation log enqueue failed: root_dir=%s domain=%s error=%s", root_dir, domain, exc, exc_info=True) def _append_operation_log(domain: str, entry: dict[str, Any]) -> None: 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 9671b106..8ac13c50 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum from app.models.chat_generation_task import ChatGenerationTask from app.models.module_generation_project import ModuleGenerationProject @@ -41,7 +43,6 @@ from app.services.generation.ai.engine_service import ( get_video_engine, parse_json_list, ) -from app.services.generation.billing_service import charge_module_prompt_usage from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.pipeline.db_lock_service import ( DatabaseRowLockBusy, @@ -56,6 +57,16 @@ from app.services.hot_opening_video_prompt_service import ( ) from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event from app.services.llm import optimize_prompt +from app.services.llm_billing import ( + LlmBillingContext, + ensure_hold_exists, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, + start_hold, +) 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, chat_tasks_by_id as _base_chat_tasks_by_id, @@ -820,6 +831,25 @@ async def submit_image_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value project.error_message = None + await start_hold( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value, + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + related_id=str(step.id), + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + description_prefix="拆镜复刻图片AI提词优化", + trace_id=f"llm-submit-hold:{step.id}", + ), + ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交") return project, step @@ -902,8 +932,37 @@ async def run_image_prompt_optimize( module_value = str(project.module) expected_step_version = int(step.version or 1) expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str) + llm_billing_context = LlmBillingContext( + user_id=user_id_value, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id_value, + attempt_no=expected_step_version, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value, + source_module=module_value, + source_project_id=project_id_value, + source_step_id=step_id_value, + source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, + related_id=step_id_value, + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value, + description_prefix="拆镜复刻图片AI提词优化", + trace_id=f"shot-image-prompt:{step_id_value}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message) + await db.commit() + return step await db.commit() + provider_succeeded = False + token_usage: dict[str, Any] = {} + log_provider_start(llm_billing_context, detail={"prompt_type": "image"}) try: request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"} log_module_prompt_event( @@ -929,6 +988,8 @@ async def run_image_prompt_optimize( log_owner_id=step_id_value, generation_attempt_no=expected_step_version, ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -942,19 +1003,25 @@ async def run_image_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await db.rollback() + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-图片AI提词优化(失效结果结算)", + ) + await db.commit() return None - billing = await charge_module_prompt_usage( + billing = await settle_success( db, - user_id=project.user_id, - step_id=step.id, + llm_billing_context, usage=token_usage, description="拆镜复刻-图片AI提词优化", ) + actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None) usage = dict(token_usage or {}) usage.update({ - "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), - "credit_biz_key": billing.items[0].biz_key if billing.items else None, + "text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value), + "credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None, }) step.status = ModuleStepStatusEnum.COMPLETED.value step.completed_at = _now() @@ -991,9 +1058,22 @@ async def run_image_prompt_optimize( await db.commit() except DatabaseRowLockBusy: await db.rollback() + if provider_succeeded: + # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-图片AI提词优化(行锁失败结算)", + ) + await db.commit() + return None + # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() + if not provider_succeeded: + log_provider_failure(llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1008,6 +1088,16 @@ async def run_image_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-图片AI提词优化(异常失效结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") + await db.commit() return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) @@ -1026,6 +1116,15 @@ async def run_image_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message) + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-图片AI提词优化(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() return step @@ -1200,6 +1299,25 @@ async def submit_video_prompt_optimize( project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value project.error_message = None + await start_hold( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value, + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + related_id=str(step.id), + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix="拆镜复刻视频AI提词优化", + trace_id=f"llm-submit-hold:{step.id}", + ), + ) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交") return project, step @@ -1280,8 +1398,37 @@ async def run_video_prompt_optimize( module_value = str(project.module) expected_step_version = int(step.version or 1) expected_input_json = json.dumps(step.input_json, ensure_ascii=False, sort_keys=True, default=str) + llm_billing_context = LlmBillingContext( + user_id=user_id_value, + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id_value, + attempt_no=expected_step_version, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value, + source_module=module_value, + source_project_id=project_id_value, + source_step_id=step_id_value, + source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value, + related_id=step_id_value, + hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value, + description_prefix="拆镜复刻视频AI提词优化", + trace_id=f"shot-video-prompt:{step_id_value}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + step.status = ModuleStepStatusEnum.FAILED.value + step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务" + step.completed_at = _now() + project.status = ModuleProjectStatusEnum.FAILED.value + project.error_message = step.error_message + await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.CHAT_TASK_FAILED.value, message=step.error_message) + await db.commit() + return step await db.commit() + provider_succeeded = False + token_usage: dict[str, Any] = {} + log_provider_start(llm_billing_context, detail={"prompt_type": "video"}) try: request_log = { "source_project_name": material.get("source_project_name") or "无", @@ -1319,6 +1466,8 @@ async def run_video_prompt_optimize( step_id=step_id_value, trace_id=f"shot-video-prompt:{step_id_value}", ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=token_usage) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1332,19 +1481,25 @@ async def run_video_prompt_optimize( expected_version=expected_step_version, expected_input_json=expected_input_json, ): - await db.rollback() + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-视频AI提词优化(失效结果结算)", + ) + await db.commit() return None - billing = await charge_module_prompt_usage( + billing = await settle_success( db, - user_id=project.user_id, - step_id=step.id, + llm_billing_context, usage=token_usage, description="拆镜复刻-视频AI提词优化", ) + actual_billing_item = next((item for item in billing.items if item.charge_key == CreditRecordChargeKind.TEXT_PROMPT.value and item.charged), None) usage = dict(token_usage or {}) usage.update({ - "text_credits_cost": (billing.items[0].amount if billing.items else billing.total_charged), - "credit_biz_key": billing.items[0].biz_key if billing.items else None, + "text_credits_cost": billing.get_amount(CreditRecordChargeKind.TEXT_PROMPT.value), + "credit_biz_key": actual_billing_item.biz_key if actual_billing_item else None, }) step.status = ModuleStepStatusEnum.COMPLETED.value step.completed_at = _now() @@ -1384,9 +1539,22 @@ async def run_video_prompt_optimize( await db.commit() except DatabaseRowLockBusy: await db.rollback() + if provider_succeeded: + # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。 + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-视频AI提词优化(行锁失败结算)", + ) + await db.commit() + return None + # Provider 尚未成功才允许同一 attempt 做系统自动重试。 raise except Exception as exc: await db.rollback() + if not provider_succeeded: + log_provider_failure(llm_billing_context, error=str(exc)) if execution_guard is not None: await execution_guard() project, step = await _reload_prompt_context_for_update( @@ -1401,6 +1569,16 @@ async def run_video_prompt_optimize( expected_input_json=expected_input_json, ): await db.rollback() + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-视频AI提词优化(异常失效结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分") + await db.commit() return None step.status = ModuleStepStatusEnum.FAILED.value step.error_message = str(exc) @@ -1419,6 +1597,15 @@ async def run_video_prompt_optimize( ) _log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc) await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message) + if provider_succeeded: + await settle_success( + db, + llm_billing_context, + usage=token_usage, + description="拆镜复刻-视频AI提词优化(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) await db.commit() return step @@ -1680,7 +1867,20 @@ async def _assert_project_has_no_active_chat_tasks_for_delete( *, project: ModuleGenerationProject, ) -> None: - """用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。""" + """用户主动删除项目/切片时不退款;如仍有异步任务进行中,直接拦截。""" + processing_result = await db.execute( + select(func.count()) + .select_from(ModuleGenerationStep) + .where( + ModuleGenerationStep.project_id == project.id, + ModuleGenerationStep.module == MODULE, + ModuleGenerationStep.deleted_at.is_(None), + ModuleGenerationStep.is_current == True, + ModuleGenerationStep.status == ModuleStepStatusEnum.PROCESSING.value, + ) + ) + if int(processing_result.scalar() or 0) > 0: + raise HTTPException(status_code=409, detail="当前拆镜复刻项目仍有 AI 任务处理中,暂不能删除") await _base_assert_project_has_no_active_chat_tasks( db, project=project, @@ -1726,6 +1926,39 @@ async def mark_shot_replicate_step_dispatch_failed( step.completed_at = _now() project.status = ModuleProjectStatusEnum.FAILED.value project.error_message = error_message + if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value): + await release_on_failure( + db, + LlmBillingContext( + user_id=str(project.user_id), + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=str(step.id), + attempt_no=int(step.version or 1), + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + billing_scene=( + CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value + if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value + ), + source_module=MODULE, + source_project_id=str(project.id), + source_step_id=str(step.id), + source_step_code=str(step.step_code), + related_id=str(step.id), + hold_config_key=( + LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value + if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value + ), + description_prefix=( + "拆镜复刻图片AI提词优化" + if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value + else "拆镜复刻视频AI提词优化" + ), + trace_id=f"shot-replicate-dispatch-failed:{step.id}", + ), + error=error_message, + ) log_module_error( module=project.module, event_type="CELERY_DISPATCH_FAILED", 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 87711eed..3a9d3739 100644 --- a/video-gen-api/app/services/shot_replicate_recovery_service.py +++ b/video-gen-api/app/services/shot_replicate_recovery_service.py @@ -9,10 +9,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.enums.celery_queue import CeleryQueue +from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState from app.enums.shot_replicate import ShotSplitStatusEnum from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.shot_replicate_task_set import ShotReplicateTaskSet -from app.services.shot_replicate_taskset_service import refresh_task_set_split_summaries +from app.services.shot_replicate_taskset_service import ( + build_segment_analysis_billing_context, + build_task_set_analysis_billing_context, + refresh_task_set_split_summaries, +) +from app.services.llm_billing import get_llm_ledger_states +from app.services.llm_billing.config import get_llm_billing_policy from app.services.celery_runtime.runtime_service import runtime_lock_values from app.tasks.celery_app import celery_app @@ -223,6 +230,17 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: ) segments = list(segment_result.scalars().all()) + billing_policy = await get_llm_billing_policy( + db, + config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + ) + billing_contexts = [ + *(build_task_set_analysis_billing_context(item) for item in task_sets), + *(build_segment_analysis_billing_context(item) for item in segments), + ] + # 配置关闭后仍需识别并继续处理已存在的 active HOLD;只有 missing 流水才按 bypass。 + billing_states = await get_llm_ledger_states(db, billing_contexts) + lock_keys: list[str] = [] task_set_lock_keys: dict[str, str] = {} segment_lock_keys: dict[str, str] = {} @@ -245,6 +263,25 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: if live_locks.get(key): results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1 continue + context = build_task_set_analysis_billing_context(item) + validation = billing_states.get(context.hold_biz_key) + can_execute = bool(validation and validation.can_execute) + state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value + if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed: + can_execute = True + state = LlmBillingLedgerState.BILLING_BYPASSED.value + elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid: + state = LlmBillingLedgerState.INVALID.value + if not can_execute: + item.analysis_status = ShotAnalysisStatusEnum.FAILED.value + item.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value + item.analysis_claim_token = None + item.analysis_started_at = None + item.analysis_lease_until = None + item.analysis_error_message = f"LLM账务状态异常({state}),恢复任务已终止" + result_key = f"task_set_billing_{state}" + results[result_key] = results.get(result_key, 0) + 1 + continue attempt = max(1, int(item.analysis_attempt_no or 1)) item.analysis_status = ShotAnalysisStatusEnum.PENDING.value item.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value @@ -259,6 +296,24 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]: if live_locks.get(key): results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1 continue + context = build_segment_analysis_billing_context(item) + validation = billing_states.get(context.hold_biz_key) + can_execute = bool(validation and validation.can_execute) + state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value + if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed: + can_execute = True + state = LlmBillingLedgerState.BILLING_BYPASSED.value + elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid: + state = LlmBillingLedgerState.INVALID.value + if not can_execute: + item.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value + item.analysis_claim_token = None + item.analysis_started_at = None + item.analysis_lease_until = None + item.analysis_error_message = f"LLM账务状态异常({state}),恢复任务已终止" + result_key = f"segment_billing_{state}" + results[result_key] = results.get(result_key, 0) + 1 + continue attempt = max(1, int(item.analysis_attempt_no or 1)) item.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value item.analysis_claim_token = None 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 cd33d39d..107a0366 100644 --- a/video-gen-api/app/services/shot_replicate_taskset_service.py +++ b/video-gen-api/app/services/shot_replicate_taskset_service.py @@ -9,6 +9,14 @@ from fastapi import HTTPException from app.config import settings from app.enums.celery_queue import CeleryQueue +from app.enums.credit_record import ( + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, + CreditRecordSourceModule, + CreditRecordSourceStepCode, +) +from app.enums.llm_billing import LlmBillingConfigKey from sqlalchemy import String, case, cast, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -46,6 +54,7 @@ from app.schemas.shot_replicate import ( ShotTaskSetOut, ) from app.services.module_generation_log_service import log_module_event_file +from app.services.llm_billing import LlmBillingContext, release_on_failure, start_hold from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum from app.services.upload_resource import release_upload_resources_by_source @@ -65,6 +74,46 @@ def _now() -> datetime: return datetime.now(timezone.utc) + + +def build_task_set_analysis_billing_context(task_set: ShotReplicateTaskSet) -> LlmBillingContext: + return LlmBillingContext( + user_id=str(task_set.user_id), + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value, + owner_id=str(task_set.id), + attempt_no=int(task_set.analysis_attempt_no or 1), + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value, + source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, + source_project_id=str(task_set.id), + source_step_id=str(task_set.id), + source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, + related_id=str(task_set.id), + hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + description_prefix="拆镜复刻原视频AI分析", + trace_id=f"shot-task-set-analysis:{task_set.id}:attempt:{int(task_set.analysis_attempt_no or 1)}", + ) + + +def build_segment_analysis_billing_context(segment: ShotReplicateSegment) -> LlmBillingContext: + return LlmBillingContext( + user_id=str(segment.user_id), + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value, + owner_id=str(segment.id), + attempt_no=int(segment.analysis_attempt_no or 1), + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value, + source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, + source_project_id=str(segment.task_set_id), + source_step_id=str(segment.id), + source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, + related_id=str(segment.id), + hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + description_prefix="拆镜复刻片段视频AI分析", + trace_id=f"shot-segment-analysis:{segment.id}:attempt:{int(segment.analysis_attempt_no or 1)}", + ) + + def _normalize_suggestions(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] @@ -177,7 +226,12 @@ async def get_segment_for_user( return segment -async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTaskSetCreate) -> ShotReplicateTaskSet: +async def create_task_set( + db: AsyncSession, + *, + current_user: User, + req: ShotTaskSetCreate, +) -> tuple[ShotReplicateTaskSet, bool]: if req.idempotency_key: existing_result = await db.execute( select(ShotReplicateTaskSet).where( @@ -188,7 +242,8 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask ) existing = existing_result.scalar_one_or_none() if existing: - return existing + # 幂等命中只返回已有任务,不重复预扣、绑定资源或投递 Celery。 + return existing, False asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds) task_set = ShotReplicateTaskSet( @@ -208,6 +263,7 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask ) db.add(task_set) await db.flush() + await start_hold(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type="SHOT_TASK_SET_CREATED", @@ -223,7 +279,7 @@ async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTask "idempotency_key": task_set.idempotency_key, }, ) - return task_set + return task_set, True def _user_name_filter_subquery(value: str): @@ -597,6 +653,8 @@ async def prepare_retry_split_segment( await refresh_task_set_split_summary(db, task_set.id) await db.flush() + # 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复预扣。 + # 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。 log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_RECEIVED.value, @@ -660,6 +718,7 @@ async def create_custom_segment( task_set.status = ShotTaskSetStatusEnum.SPLITTING.value task_set.split_status = ShotSplitStatusEnum.PROCESSING.value await db.flush() + await start_hold(db, build_segment_analysis_billing_context(segment)) await refresh_task_set_split_summary(db, task_set.id) await db.flush() log_module_event_file( @@ -821,11 +880,18 @@ async def delete_segment( ) if segment.split_status == ShotSplitStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail="当前拆镜片段正在切割处理中,暂不能删除") + raise HTTPException(status_code=409, detail="当前拆镜片段正在切割处理中,暂不能删除") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail="当前拆镜片段正在分析处理中,暂不能删除") + raise HTTPException(status_code=409, detail="当前拆镜片段正在分析处理中,暂不能删除") if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除") + raise HTTPException(status_code=409, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除") + + if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: + await release_on_failure( + db, + build_segment_analysis_billing_context(segment), + error="用户删除自定义拆镜片段,释放未结算的片段分析预扣", + ) deleted_at = _now() released_size_bytes = await soft_delete_resources_by_source( @@ -882,7 +948,8 @@ async def delete_segment( "upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"}, "pending_delete_resource_count": len(pending_delete_resource_ids), "physical_file_delete": "after_commit", - "refund": False, + "media_refund": False, + "llm_hold_release_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, }, ) @@ -914,9 +981,9 @@ async def delete_task_set( user_id_snapshot = task_set.user_id if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail="原视频分析正在处理中,暂不能删除任务集") + raise HTTPException(status_code=409, detail="原视频分析正在处理中,暂不能删除任务集") if task_set.split_status == ShotSplitStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail="拆镜切片正在处理中,暂不能删除任务集") + raise HTTPException(status_code=409, detail="拆镜切片正在处理中,暂不能删除任务集") segments_result = await db.execute( select(ShotReplicateSegment) @@ -929,11 +996,26 @@ async def delete_task_set( segments = list(segments_result.scalars().all()) for segment in segments: if segment.split_status == ShotSplitStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集") + raise HTTPException(status_code=409, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集") + raise HTTPException(status_code=409, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集") if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value: - raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集") + raise HTTPException(status_code=409, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集") + + # 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。 + # 这里仅做账务补偿,不改变现有逐项目删除流程。 + await release_on_failure( + db, + build_task_set_analysis_billing_context(task_set), + error="用户删除拆镜任务集,释放未结算的原视频分析预扣", + ) + for segment in segments: + if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: + await release_on_failure( + db, + build_segment_analysis_billing_context(segment), + error="用户删除拆镜任务集,释放未结算的片段分析预扣", + ) segment_ids = [segment.id for segment in segments] module_project_ids = [segment.module_project_id for segment in segments if segment.module_project_id] @@ -997,7 +1079,8 @@ async def delete_task_set( "task_upload_release": {k: v for k, v in task_upload_release.items() if k != "released_resource_ids"}, "segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"}, "physical_file_delete": "after_commit", - "refund": False, + "media_refund": False, + "llm_hold_release_on_cancel": True, }, ) return ShotTaskSetDeleteOut( @@ -1061,6 +1144,7 @@ async def prepare_reanalyze_task_set( task_set.analysis_raw_json = None task_set.analysis_result_json = None await db.flush() + await start_hold(db, build_task_set_analysis_billing_context(task_set)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value, @@ -1073,6 +1157,7 @@ async def prepare_reanalyze_task_set( message="原视频再次分析任务已准备投递", task_set_id=task_set.id, segment_id=None, + analysis_attempt_no=max(1, int(task_set.analysis_attempt_no or 1)), analysis_status=task_set.analysis_status, celery_task_name="shot_replicate.analyze_original_video", ) @@ -1123,6 +1208,7 @@ async def prepare_reanalyze_segment( segment.segment_category = None segment.segment_audience = None await db.flush() + await start_hold(db, build_segment_analysis_billing_context(segment)) log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value, @@ -1136,6 +1222,107 @@ async def prepare_reanalyze_segment( message="切片视频再次分析任务已准备投递", task_set_id=segment.task_set_id, segment_id=segment.id, + analysis_attempt_no=max(1, int(segment.analysis_attempt_no or 1)), analysis_status=segment.analysis_status, celery_task_name="shot_replicate.analyze_custom_segment_video", ) + + +async def mark_task_set_analysis_dispatch_failed( + db: AsyncSession, + *, + current_user: User, + task_set_id: str, + error_message: str, +) -> bool: + task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) + if ( + task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value + and task_set.analysis_claim_token + and task_set.analysis_lease_until + and task_set.analysis_lease_until > _now() + ): + return False + if task_set.analysis_status in (ShotAnalysisStatusEnum.COMPLETED.value, ShotAnalysisStatusEnum.FAILED.value): + return False + if task_set.analysis_status not in (ShotAnalysisStatusEnum.COMPLETED.value, ShotAnalysisStatusEnum.FAILED.value): + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value + task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value + task_set.analysis_claim_token = None + task_set.analysis_lease_until = None + task_set.analysis_error_message = error_message + await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message) + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=task_set.id, + user_id=task_set.user_id, + message=error_message, + detail={"task_set_id": task_set.id, "reason": "analysis_dispatch_failed"}, + error=error_message, + ) + return True + + +async def mark_segment_analysis_dispatch_failed( + db: AsyncSession, + *, + current_user: User, + segment_id: str, + error_message: str, +) -> bool: + segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) + if ( + segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value + and segment.analysis_claim_token + and segment.analysis_lease_until + and segment.analysis_lease_until > _now() + ): + return False + if segment.analysis_status in (ShotSegmentAnalysisStatusEnum.COMPLETED.value, ShotSegmentAnalysisStatusEnum.FAILED.value): + return False + if segment.analysis_status not in (ShotSegmentAnalysisStatusEnum.COMPLETED.value, ShotSegmentAnalysisStatusEnum.FAILED.value): + segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value + segment.analysis_claim_token = None + segment.analysis_lease_until = None + segment.analysis_error_message = error_message + await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message) + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=segment.task_set_id, + step_id=segment.id, + user_id=segment.user_id, + message=error_message, + detail={"segment_id": segment.id, "reason": "analysis_dispatch_failed"}, + error=error_message, + ) + return True + + +async def mark_custom_segment_split_dispatch_failed( + db: AsyncSession, + *, + current_user: User, + segment_id: str, + error_message: str, +) -> None: + segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) + if segment.split_status not in (ShotSplitStatusEnum.COMPLETED.value, ShotSplitStatusEnum.FAILED.value): + segment.split_status = ShotSplitStatusEnum.FAILED.value + segment.split_claim_token = None + segment.split_lease_until = None + segment.split_next_retry_at = None + segment.split_last_error = error_message + # 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用 + # 原 active HOLD;只有片段分析最终失败或用户删除片段时才释放。 + log_module_event_file( + module=MODULE, + event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, + project_id=segment.task_set_id, + step_id=segment.id, + user_id=segment.user_id, + message=error_message, + detail={"segment_id": segment.id, "reason": "split_dispatch_failed"}, + error=error_message, + ) diff --git a/video-gen-api/app/services/shot_video_analysis_service.py b/video-gen-api/app/services/shot_video_analysis_service.py index 6039511b..2c72779f 100644 --- a/video-gen-api/app/services/shot_video_analysis_service.py +++ b/video-gen-api/app/services/shot_video_analysis_service.py @@ -73,9 +73,15 @@ def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4") # return f"data:{mime};base64,{b64}" -async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[dict[str, Any], dict[str, Any], str]: - from app.utils.media import media_to_base64, get_llm_media_as_base64 - if await get_llm_media_as_base64(db): +async def build_user_message( + user_text: str, + video_url: str, + *, + as_base64: bool, +) -> tuple[dict[str, Any], dict[str, Any], str]: + from app.utils.media import media_to_base64 + + if as_base64: real_url = await media_to_base64(video_url, "video/mp4") else: real_url = build_file_url_or_data_uri(video_url) @@ -583,9 +589,20 @@ async def analyze_video_for_shot_split( if not str(config.model_name or "").strip(): raise RuntimeError(f"拆镜分析模型名称为空: model_config_id={config.id}") + from app.utils.media import get_llm_media_as_base64 + + as_base64 = await get_llm_media_as_base64(db) + # 模型配置和媒体传输开关读取完毕后立即释放事务;后续文件读取/Base64 + # 转换及最长一小时的远程请求都不能占用数据库连接。 + await db.rollback() + system_prompt = build_video_analysis_system_prompt(mode=mode) user_text = build_video_analysis_user_text(mode=mode) - user_message, log_user_message, real_video_url = await build_user_message(user_text, video_url, db) + user_message, log_user_message, real_video_url = await build_user_message( + user_text, + video_url, + as_base64=as_base64, + ) request_data: dict[str, Any] = { "model": config.model_name, @@ -614,8 +631,6 @@ async def analyze_video_for_shot_split( } url = f"{str(config.api_base).rstrip('/')}/chat/completions" - # 释放模型配置和媒体开关查询产生的事务;HTTP 调用期间不占用数据库连接。 - await db.rollback() _log_shot_ai_model_event( call_id=call_id, diff --git a/video-gen-api/app/services/system_config_cache.py b/video-gen-api/app/services/system_config_cache.py new file mode 100644 index 00000000..f73f319c --- /dev/null +++ b/video-gen-api/app/services/system_config_cache.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Iterable + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.system_config import SystemConfig +from app.utils.redis import get_redis + +_CACHE_VERSION_KEY = "system_config_cache_version" +_DEFAULT_TTL_SECONDS = 60 + + +@dataclass +class _CacheState: + values: dict[str, str | None] + expires_at: float + version: str | None + + +_cache = _CacheState(values={}, expires_at=0.0, version=None) + + +async def _get_remote_version() -> str | None: + redis = get_redis() + if not redis: + return None + try: + value = await redis.get(_CACHE_VERSION_KEY) + return str(value or "0") + except Exception: + return None + + +async def invalidate_system_config_cache(keys: Iterable[str] | None = None) -> None: + """Invalidate local cache and notify other workers through a Redis version bump when available.""" + key_set = set(keys or []) + if key_set: + for key in key_set: + _cache.values.pop(key, None) + else: + _cache.values.clear() + _cache.expires_at = 0.0 + + redis = get_redis() + if redis: + try: + await redis.incr(_CACHE_VERSION_KEY) + except Exception: + pass + + +async def get_system_config_values( + db: AsyncSession, + keys: Iterable[str], + *, + ttl_seconds: int = _DEFAULT_TTL_SECONDS, +) -> dict[str, str | None]: + key_list = [str(key) for key in keys if str(key)] + if not key_list: + return {} + + now = time.monotonic() + remote_version = await _get_remote_version() + if remote_version is not None and remote_version != _cache.version: + _cache.values.clear() + _cache.expires_at = 0.0 + _cache.version = remote_version + + missing = [key for key in key_list if key not in _cache.values] + if now >= _cache.expires_at: + missing = key_list + + if missing: + result = await db.execute(select(SystemConfig).where(SystemConfig.key.in_(missing))) + rows = {row.key: row.value for row in result.scalars().all()} + for key in missing: + _cache.values[key] = rows.get(key) + _cache.expires_at = now + max(1, int(ttl_seconds or _DEFAULT_TTL_SECONDS)) + if remote_version is not None: + _cache.version = remote_version + + return {key: _cache.values.get(key) for key in key_list} + + +async def get_system_config_value(db: AsyncSession, key: str, *, ttl_seconds: int = _DEFAULT_TTL_SECONDS) -> str | None: + return (await get_system_config_values(db, [key], ttl_seconds=ttl_seconds)).get(key) diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py index 17c56f8e..b3b04f46 100644 --- a/video-gen-api/app/tasks/shot_replicate_tasks.py +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -8,7 +8,8 @@ from typing import Any from sqlalchemy import select, update from app.config import settings -from app.enums.credit_record import CreditRecordBillingScene, CreditRecordOwnerType +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSourceStepCode +from app.enums.llm_billing import LlmBillingConfigKey from app.enums.celery_queue import CeleryQueue, CeleryTaskName from app.enums.celery_runtime import CeleryRuntimeDomain from app.enums.shot_replicate import ( @@ -34,12 +35,21 @@ from app.services.celery_runtime.recovery_service import guard_periodic_recovery from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary from app.services.shot_video_analysis_service import analyze_video_for_shot_split -from app.services.generation.billing_service import charge_shot_video_analysis_usage +from app.services.llm_billing import ( + LlmBillingContext, + ensure_hold_exists, + log_provider_failure, + log_provider_start, + log_provider_success, + release_on_failure, + settle_success, +) from app.services.shot_video_split_service import cleanup_split_result, finalize_split_result, split_video_segment_async from app.services.upload_video_asset_service import validate_split_range from app.services.upload_resource import record_shot_segment_upload_resource from app.tasks.async_runner import run_async from app.tasks.celery_app import celery_app +from app.utils.exceptions import InsufficientCreditsError logger = logging.getLogger("video_gen") @@ -187,6 +197,40 @@ async def _run_analyze_original_video(task_set_id: str) -> None: task_set.analysis_started_at = _now() task_set.analysis_lease_until = _now() + timedelta(seconds=int(settings.SHOT_ANALYSIS_LEASE_SECONDS or 180)) task_set.analysis_error_message = None + llm_billing_context = LlmBillingContext( + user_id=task_set_user_id, + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value, + owner_id=task_set_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value, + source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, + source_project_id=task_set_id, + source_step_id=task_set_id, + source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, + related_id=task_set_id, + hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + description_prefix="拆镜复刻原视频分析", + trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止原视频分析任务" + task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value + task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value + task_set.analysis_claim_token = None + task_set.analysis_lease_until = None + task_set.analysis_error_message = error_message + await db.commit() + log_module_error( + module=MODULE, + event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value, + project_id=task_set_id, + user_id=task_set_user_id, + message=error_message, + detail={"task_set_id": task_set_id, "analysis_attempt_no": attempt_no}, + ) + return await db.commit() log_module_event_file( @@ -204,6 +248,12 @@ async def _run_analyze_original_video(task_set_id: str) -> None: }, ) + provider_succeeded = False + analyzed = None + log_provider_start( + llm_billing_context, + detail={"analysis_mode": "full_breakdown", "video_url": video_url}, + ) async with async_session() as call_db: analyzed = await analyze_video_for_shot_split( call_db, @@ -213,6 +263,8 @@ async def _run_analyze_original_video(task_set_id: str) -> None: task_set_id=task_set_id, trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}", ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=analyzed.usage) await lease.ensure_owned() result = await call_db.execute( select(ShotReplicateTaskSet) @@ -229,6 +281,14 @@ async def _run_analyze_original_video(task_set_id: str) -> None: or task_set.analysis_status != ShotAnalysisStatusEnum.PROCESSING.value ): await call_db.rollback() + # Provider 已成功,旧业务对象失效也必须按真实 usage 结算。 + await settle_success( + call_db, + llm_billing_context, + usage=analyzed.usage, + description="拆镜复刻-原视频分析(失效结果结算)", + ) + await call_db.commit() return result_json = analyzed.result task_set.original_video_content = str(result_json.get("原视频内容") or "无") @@ -242,16 +302,11 @@ async def _run_analyze_original_video(task_set_id: str) -> None: task_set.analysis_claim_token = None task_set.analysis_lease_until = None task_set.analysis_error_message = None - await charge_shot_video_analysis_usage( + await settle_success( call_db, - user_id=task_set.user_id, - owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value, - owner_id=task_set.id, + llm_billing_context, usage=analyzed.usage, description="拆镜复刻-原视频分析", - billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value, - source_project_id=task_set.id, - attempt_no=attempt_no, ) await call_db.commit() @@ -277,6 +332,8 @@ async def _run_analyze_original_video(task_set_id: str) -> None: except RedisExecutionLockError: raise except Exception as exc: + if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): + log_provider_failure(llm_billing_context, error=str(exc)) async with async_session() as db: result = await db.execute( select(ShotReplicateTaskSet) @@ -285,21 +342,32 @@ async def _run_analyze_original_video(task_set_id: str) -> None: .limit(1) ) task_set = result.scalar_one_or_none() - if ( + task_set_is_current = bool( task_set and int(task_set.analysis_attempt_no or 1) == attempt_no and task_set.analysis_claim_token == token and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value - ): + ) + if task_set_is_current and task_set is not None: task_set_user_id = task_set_user_id or str(task_set.user_id) task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value task_set.analysis_claim_token = None task_set.analysis_lease_until = None task_set.analysis_error_message = str(exc) - await db.commit() - else: - await db.rollback() + # 业务对象是否仍有效,不影响本 attempt 的账务终态:provider 已成功必须结算, + # provider 未成功则幂等释放。这样人工删库/异常换 attempt 也不会遗留 active HOLD。 + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None: + await settle_success( + db, + llm_billing_context, + usage=analyzed.usage, + description="拆镜复刻-原视频分析(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) + await db.commit() log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value, @@ -379,6 +447,40 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: segment.analysis_started_at = _now() segment.analysis_lease_until = _now() + timedelta(seconds=int(settings.SHOT_ANALYSIS_LEASE_SECONDS or 180)) segment.analysis_error_message = None + llm_billing_context = LlmBillingContext( + user_id=user_id, + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value, + owner_id=segment_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value, + source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, + source_project_id=task_set_id, + source_step_id=segment_id, + source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, + related_id=segment_id, + hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value, + description_prefix="拆镜复刻片段视频分析", + trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", + ) + hold_validation = await ensure_hold_exists(db, llm_billing_context) + if not hold_validation.can_execute: + error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止片段视频分析任务" + segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value + segment.analysis_claim_token = None + segment.analysis_lease_until = None + segment.analysis_error_message = error_message + await db.commit() + log_module_error( + module=MODULE, + event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value, + project_id=task_set_id, + step_id=segment_id, + user_id=user_id, + message=error_message, + detail={"segment_id": segment_id, "task_set_id": task_set_id, "analysis_attempt_no": attempt_no}, + ) + return await db.commit() log_module_event_file( @@ -391,6 +493,12 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: detail={"segment_id": segment_id, "task_set_id": task_set_id, "video_url": video_url, "analysis_attempt_no": attempt_no}, ) + provider_succeeded = False + analyzed = None + log_provider_start( + llm_billing_context, + detail={"analysis_mode": "summary_only", "video_url": video_url}, + ) async with async_session() as call_db: analyzed = await analyze_video_for_shot_split( call_db, @@ -401,6 +509,8 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: segment_id=segment_id, trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", ) + provider_succeeded = True + log_provider_success(llm_billing_context, usage=analyzed.usage) await lease.ensure_owned() result = await call_db.execute( select(ShotReplicateSegment) @@ -417,6 +527,13 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: or segment.analysis_status != ShotSegmentAnalysisStatusEnum.PROCESSING.value ): await call_db.rollback() + await settle_success( + call_db, + llm_billing_context, + usage=analyzed.usage, + description="拆镜复刻-片段视频分析(失效结果结算)", + ) + await call_db.commit() return result_json = analyzed.result segment.original_video_content = str(result_json.get("原视频内容") or "无") @@ -430,17 +547,11 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: segment.analysis_claim_token = None segment.analysis_lease_until = None segment.analysis_error_message = None - await charge_shot_video_analysis_usage( + await settle_success( call_db, - user_id=segment.user_id, - owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value, - owner_id=segment.id, + llm_billing_context, usage=analyzed.usage, description="拆镜复刻-片段视频分析", - billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value, - source_project_id=segment.task_set_id, - source_step_id=segment.id, - attempt_no=attempt_no, ) await call_db.commit() @@ -467,6 +578,8 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: except RedisExecutionLockError: raise except Exception as exc: + if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False): + log_provider_failure(llm_billing_context, error=str(exc)) async with async_session() as db: result = await db.execute( select(ShotReplicateSegment) @@ -475,21 +588,30 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: .limit(1) ) segment = result.scalar_one_or_none() - if ( + segment_is_current = bool( segment and int(segment.analysis_attempt_no or 1) == attempt_no and segment.analysis_claim_token == token and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value - ): + ) + if segment_is_current and segment is not None: user_id = user_id or str(segment.user_id) task_set_id = task_set_id or str(segment.task_set_id) segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value segment.analysis_claim_token = None segment.analysis_lease_until = None segment.analysis_error_message = str(exc) - await db.commit() - else: - await db.rollback() + if "llm_billing_context" in locals(): + if locals().get("provider_succeeded", False) and locals().get("analyzed") is not None: + await settle_success( + db, + llm_billing_context, + usage=analyzed.usage, + description="拆镜复刻-片段视频分析(本地失败结算)", + ) + else: + await release_on_failure(db, llm_billing_context, error=str(exc)) + await db.commit() log_module_error( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value, @@ -761,6 +883,8 @@ async def _run_split_one_segment(segment_id: str) -> None: segment.split_status = ShotSplitStatusEnum.FAILED.value segment.split_next_retry_at = None final_failed = True + # 切片最终失败不释放片段分析 HOLD;手动切片重试继续沿用 + # 原 attempt。用户最终删除片段/任务集时再做取消补偿。 else: segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value segment.split_next_retry_at = _retry_at(attempt)