From 468184d352a1523ad9ed3bb1a5a762ffebf9c32f Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Fri, 10 Jul 2026 16:57:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=B5=81=E6=B0=B4=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=98=8E=E7=BB=86=20|=20AI=E5=BC=95=E6=93=8E=E8=AE=A1?= =?UTF-8?q?=E4=BB=B7=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- video-gen-admin/src/App.tsx | 2 + video-gen-admin/src/api/index.ts | 43 + .../modelPricing/PricingRuleForm.tsx | 486 +++++++++ .../modelPricing/PricingRulePreview.tsx | 229 ++++ .../src/pages/AdminCreditRecords.tsx | 473 ++++----- .../src/pages/AdminModelPricingRules.tsx | 146 +++ video-gen-admin/src/types/index.ts | 119 +++ ...3f0_add_model_pricing_rules_and_credit_.py | 582 +++++++++++ video-gen-api/app/api/admin/__init__.py | 2 + video-gen-api/app/api/admin/model_pricing.py | 238 +++++ video-gen-api/app/api/v1/admin.py | 40 +- video-gen-api/app/api/v1/generation.py | 66 +- video-gen-api/app/api/v1/generation_ai.py | 5 + .../cmd/backfill_credit_record_snapshots.py | 981 ++++++++++++++++++ .../app/cmd/seed_model_pricing_rules.py | 115 ++ video-gen-api/app/config.py | 2 + video-gen-api/app/enums/model_pricing.py | 114 ++ video-gen-api/app/models/__init__.py | 3 +- .../app/models/chat_generation_task.py | 2 + video-gen-api/app/models/credit_record.py | 63 +- video-gen-api/app/models/generation_record.py | 5 + .../app/models/model_pricing_rule.py | 63 ++ video-gen-api/app/schemas/admin.py | 46 + video-gen-api/app/schemas/model_pricing.py | 86 ++ .../services/admin_credit_record_service.py | 202 +++- .../services/credit_record_meta_service.py | 166 ++- .../app/services/generation_ai_service.py | 8 + .../services/generation_billing_service.py | 21 + .../app/services/generation_prompt_service.py | 5 +- .../services/generation_provider_service.py | 11 +- .../services/generation_recovery_service.py | 106 +- .../generation_task_factory_service.py | 8 + .../hot_opening_video_prompt_service.py | 5 +- video-gen-api/app/services/image_gen.py | 43 +- video-gen-api/app/services/llm.py | 5 +- .../media_token_usage_snapshot_service.py | 350 ++++--- .../app/services/model_pricing/__init__.py | 7 + .../attachment_snapshot_service.py | 323 ++++++ .../app/services/model_pricing/calculator.py | 558 ++++++++++ .../services/model_pricing/rule_service.py | 517 +++++++++ .../app/services/model_pricing/seed_data.py | 175 ++++ .../model_pricing/snapshot_service.py | 585 +++++++++++ .../model_pricing/usage_normalizer.py | 275 +++++ .../app/services/operation_log_service.py | 51 + .../services/shot_video_analysis_service.py | 5 +- video-gen-api/app/services/video_queue.py | 118 ++- video-gen-api/app/tasks/celery_app.py | 2 + .../app/tasks/generation_create_tasks.py | 32 +- .../app/tasks/generation_download_tasks.py | 7 +- .../app/tasks/generation_poll_tasks.py | 8 +- 50 files changed, 6998 insertions(+), 506 deletions(-) create mode 100644 video-gen-admin/src/components/modelPricing/PricingRuleForm.tsx create mode 100644 video-gen-admin/src/components/modelPricing/PricingRulePreview.tsx create mode 100644 video-gen-admin/src/pages/AdminModelPricingRules.tsx create mode 100644 video-gen-api/alembic/versions/1c93b40133f0_add_model_pricing_rules_and_credit_.py create mode 100644 video-gen-api/app/api/admin/model_pricing.py create mode 100644 video-gen-api/app/cmd/backfill_credit_record_snapshots.py create mode 100644 video-gen-api/app/cmd/seed_model_pricing_rules.py create mode 100644 video-gen-api/app/enums/model_pricing.py create mode 100644 video-gen-api/app/models/model_pricing_rule.py create mode 100644 video-gen-api/app/schemas/model_pricing.py create mode 100644 video-gen-api/app/services/model_pricing/__init__.py create mode 100644 video-gen-api/app/services/model_pricing/attachment_snapshot_service.py create mode 100644 video-gen-api/app/services/model_pricing/calculator.py create mode 100644 video-gen-api/app/services/model_pricing/rule_service.py create mode 100644 video-gen-api/app/services/model_pricing/seed_data.py create mode 100644 video-gen-api/app/services/model_pricing/snapshot_service.py create mode 100644 video-gen-api/app/services/model_pricing/usage_normalizer.py diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index 92eaf1d6..cb13db0f 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -14,6 +14,7 @@ import AdminModels from './pages/AdminModels'; import AdminSettings from './pages/AdminSettings'; import AdminNotificationManager from './pages/AdminNotificationManager'; import AdminCreditRecords from './pages/AdminCreditRecords'; +import AdminModelPricingRules from './pages/AdminModelPricingRules'; import AdminPaymentConfig from './pages/AdminPaymentConfig'; import AdminPaymentStats from './pages/AdminPaymentStats'; import AdminIndustries from './pages/AdminIndustries'; @@ -87,6 +88,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index ec4c4191..bce92b9b 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -13,6 +13,7 @@ import type { VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload, VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut, AdminCreditRecordListResponse, AdminCreditRecordQueryParams, + ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse, ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, @@ -296,12 +297,54 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): setMaybe(params, 'source_module', filters?.sourceModule); setMaybe(params, 'source_step_code', filters?.sourceStepCode); setMaybe(params, 'billing_scene', filters?.billingScene); + setMaybe(params, 'engine_provider', filters?.engineProvider); + setMaybe(params, 'engine_model_name', filters?.engineModelName); + setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode); + setMaybe(params, 'provider_cost_status', filters?.providerCostStatus); + setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated); + setMaybe(params, 'has_attachment', filters?.hasAttachment); setMaybe(params, 'start_date', filters?.startDate); setMaybe(params, 'end_date', filters?.endDate); const q = params.toString() ? `?${params}` : ''; return api.get(`/admin/credit-records${q}`); } + +export async function getModelPricingRules(filters?: { + page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string; +}): Promise { + const params = new URLSearchParams(); + setMaybe(params, 'page', filters?.page); + setMaybe(params, 'page_size', filters?.pageSize); + setMaybe(params, 'provider', filters?.provider); + setMaybe(params, 'model_name', filters?.modelName); + setMaybe(params, 'model_category', filters?.modelCategory); + setMaybe(params, 'publish_status', filters?.publishStatus); + return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`); +} + +export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise { + return api.post('/admin/model-pricing/rules', payload); +} + +export async function updateModelPricingRule(id: string, payload: Partial): Promise { + return api.put(`/admin/model-pricing/rules/${id}`, payload); +} + +export async function publishModelPricingRule(id: string): Promise { + return api.post(`/admin/model-pricing/rules/${id}/publish`); +} + +export async function disableModelPricingRule(id: string): Promise { + return api.post(`/admin/model-pricing/rules/${id}/disable`); +} + +export async function previewModelPricing(payload: { + billing_mode: string; calculator_version: string; rule_json: Record; usage: Record; currency?: string; +}): Promise { + return api.post('/admin/model-pricing/preview', payload); +} + export async function getIndustryConfigs(): Promise { return api.get('/admin/industry-configs'); } diff --git a/video-gen-admin/src/components/modelPricing/PricingRuleForm.tsx b/video-gen-admin/src/components/modelPricing/PricingRuleForm.tsx new file mode 100644 index 00000000..1f3de13b --- /dev/null +++ b/video-gen-admin/src/components/modelPricing/PricingRuleForm.tsx @@ -0,0 +1,486 @@ +import React, { useEffect } from 'react'; +import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd'; +import dayjs from 'dayjs'; +import type { + ModelPricingBillingMode, + ModelPricingCalculatorVersion, + ModelPricingRule, + ModelPricingRulePayload, +} from '../../types'; + +interface Props { + initial?: ModelPricingRule | null; + loading?: boolean; + onSubmit: (payload: ModelPricingRulePayload) => Promise | void; + onCancel: () => void; +} + +const modeOptions = [ + { value: 'text_token_tiered', label: '文本分档 Token 计价' }, + { value: 'image_per_output', label: '按输出图片数量计价' }, + { value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' }, + { value: 'video_token_rate', label: '视频像素 Token 计价' }, +]; + +const calculatorByMode: Record = { + text_token_tiered: 'text_token_tiered_v1', + image_per_output: 'image_per_output_v1', + image_input_output_tiered: 'image_input_output_tiered_v1', + video_token_rate: 'video_pixel_token_v1', +}; + +const billByOptions = [ + { value: 'successful_output_count', label: '实际成功输出数' }, + { value: 'requested_output_count', label: '请求输出数(估算)' }, + { value: 'provider_billed_count', label: '供应商明确计费数' }, +]; + +function splitList(value?: string): string[] { + return String(value || '').split(',').map(v => v.trim()).filter(Boolean); +} + +function readAlias(source: Record, snakeKey: string, camelKey: string): any { + if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey]; + return source[camelKey]; +} + +function removeKeys(source: Record, keys: string[]): Record { + const result = { ...source }; + keys.forEach(key => delete result[key]); + return result; +} + +function normalizeTextTier(value: Record): Record { + const result = removeKeys(value || {}, [ + 'max_context_tokens', 'maxContextTokens', + 'input_rate', 'inputRate', + 'audio_input_rate', 'audioInputRate', + 'output_rate', 'outputRate', + 'cached_input_rate', 'cachedInputRate', + 'cached_audio_input_rate', 'cachedAudioInputRate', + ]); + const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens'); + return { + ...result, + max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens, + input_rate: readAlias(value || {}, 'input_rate', 'inputRate'), + audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'), + output_rate: readAlias(value || {}, 'output_rate', 'outputRate'), + cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'), + cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'), + }; +} + +function normalizeOutputTier(value: Record): Record { + const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']); + const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels'); + return { + ...result, + max_pixels: maxPixels === undefined ? null : maxPixels, + rate: value?.rate, + }; +} + +function normalizeVideoRate(value: Record): Record { + const result = removeKeys(value || {}, [ + 'has_input_video', 'hasInputVideo', + 'generate_audio', 'generateAudio', + 'inference_modes', 'inferenceModes', + ]); + const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo'); + const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio'); + const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes'); + return { + ...result, + ...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }), + ...(generateAudio === undefined ? {} : { generate_audio: generateAudio }), + ...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }), + }; +} + +/** + * 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。 + * 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。 + */ +function normalizeRuleJson( + mode: ModelPricingBillingMode, + value: Record, +): Record { + const source = value || {}; + const base = removeKeys(source, [ + 'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour', + 'output_rate', 'outputRate', + 'bill_by', 'billBy', + 'free_input_images', 'freeInputImages', + 'input_image_rate', 'inputImageRate', + 'output_tiers', 'outputTiers', + 'token_formula', 'tokenFormula', + 'default_fps', 'defaultFps', + 'supported_resolutions', 'supportedResolutions', + 'dimension_map', 'dimensionMap', + ]); + + if (mode === 'text_token_tiered') { + return { + ...base, + cache_storage_rate_per_million_token_hour: readAlias( + source, + 'cache_storage_rate_per_million_token_hour', + 'cacheStorageRatePerMillionTokenHour', + ), + tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})), + }; + } + + if (mode === 'image_per_output') { + return { + ...base, + output_rate: readAlias(source, 'output_rate', 'outputRate'), + bill_by: readAlias(source, 'bill_by', 'billBy'), + }; + } + + if (mode === 'image_input_output_tiered') { + const outputTiers = readAlias(source, 'output_tiers', 'outputTiers'); + return { + ...base, + free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'), + input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'), + output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})), + bill_by: readAlias(source, 'bill_by', 'billBy'), + }; + } + + return { + ...base, + token_formula: readAlias(source, 'token_formula', 'tokenFormula'), + default_fps: readAlias(source, 'default_fps', 'defaultFps'), + supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'), + dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {}, + rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})), + }; +} + +function dimensionRows(rule: Record): Array> { + const rows: Array> = []; + Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => { + Object.entries((ratios || {}) as Record).forEach(([aspectRatio, value]) => { + const item = value as Record; + rows.push({ resolution, aspectRatio, width: item.width, height: item.height }); + }); + }); + return rows; +} + +function toFormPricing(mode: ModelPricingBillingMode, rule: Record): Record { + const normalized = normalizeRuleJson(mode, rule || {}); + if (mode === 'text_token_tiered') { + return { + tiers: normalized.tiers || [], + cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0, + }; + } + if (mode === 'image_per_output') { + return { + outputRate: normalized.output_rate ?? 0, + billBy: normalized.bill_by || 'successful_output_count', + }; + } + if (mode === 'image_input_output_tiered') { + return { + freeInputImages: normalized.free_input_images ?? 1, + inputImageRate: normalized.input_image_rate ?? 0, + outputTiers: normalized.output_tiers || [], + billBy: normalized.bill_by || 'successful_output_count', + }; + } + return { + tokenFormula: normalized.token_formula + || '(input_video_seconds + output_video_seconds) * width * height * fps / 1024', + supportedResolutions: (normalized.supported_resolutions || []).join(','), + defaultFps: normalized.default_fps ?? 30, + dimensionRows: dimensionRows(normalized), + rates: (normalized.rates || []).map((r: any) => ({ + ...r, + resolutions: (r.resolutions || []).join(','), + inferenceModes: (r.inference_modes || []).join(','), + hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false', + generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false', + })), + }; +} + +function buildDimensionMap(rows: Array>): Record { + const result: Record = {}; + (rows || []).forEach(row => { + const resolution = String(row.resolution || '').trim().toLowerCase(); + const aspectRatio = String(row.aspectRatio || '').trim(); + const width = Number(row.width || 0); + const height = Number(row.height || 0); + if (!resolution || !aspectRatio || width <= 0 || height <= 0) return; + result[resolution] ||= {}; + result[resolution][aspectRatio] = { width, height }; + }); + return result; +} + +function buildRuleJson( + mode: ModelPricingBillingMode, + pricing: Record, + baseRule: Record, +): Record { + const base = normalizeRuleJson(mode, baseRule || {}); + if (mode === 'text_token_tiered') { + return { + ...base, + unit: 'CNY_per_million_tokens', + cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0), + tiers: (pricing.tiers || []).map((v: any) => ({ + max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined + ? null + : readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'), + input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), + audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), + output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0), + cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0), + cached_audio_input_rate: String( + readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate') + ?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') + ?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate') + ?? readAlias(v || {}, 'input_rate', 'inputRate') + ?? 0, + ), + })), + }; + } + if (mode === 'image_per_output') { + return { + ...base, + unit: 'CNY_per_image', + output_rate: String(pricing.outputRate ?? 0), + bill_by: pricing.billBy || 'successful_output_count', + }; + } + if (mode === 'image_input_output_tiered') { + return { + ...base, + unit: 'CNY_per_image', + free_input_images: Number(pricing.freeInputImages || 0), + input_image_rate: String(pricing.inputImageRate ?? 0), + output_tiers: (pricing.outputTiers || []).map((v: any) => ({ + max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined + || readAlias(v || {}, 'max_pixels', 'maxPixels') === null + || readAlias(v || {}, 'max_pixels', 'maxPixels') === '' + ? null + : Number(readAlias(v || {}, 'max_pixels', 'maxPixels')), + rate: String(v.rate ?? 0), + })), + bill_by: pricing.billBy || 'successful_output_count', + }; + } + return { + ...base, + unit: 'CNY_per_million_tokens', + token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024', + default_fps: Number(pricing.defaultFps || 30), + supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()), + dimension_map: buildDimensionMap(pricing.dimensionRows || []), + rates: (pricing.rates || []).map((v: any) => ({ + ...(splitList(v.resolutions).length + ? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) } + : {}), + ...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined + ? { has_input_video: v.hasInputVideo === 'true' } + : {}), + ...(v.generateAudio !== 'any' && v.generateAudio !== undefined + ? { generate_audio: v.generateAudio === 'true' } + : {}), + ...(splitList(v.inferenceModes).length + ? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) } + : {}), + rate: String(v.rate ?? 0), + })), + }; +} + +const PricingRuleForm: React.FC = ({ initial, loading, onSubmit, onCancel }) => { + const [form] = Form.useForm(); + const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined; + + useEffect(() => { + const billingMode = initial?.billingMode || 'video_token_rate'; + form.setFieldsValue({ + provider: initial?.provider || 'volcengine', + modelName: initial?.modelName || '', + modelCategory: initial?.modelCategory || 'video', + billingMode, + calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode], + versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`, + effectiveRange: [ + dayjs(initial?.effectiveFrom || undefined), + initial?.effectiveTo ? dayjs(initial.effectiveTo) : null, + ], + currency: initial?.currency || 'CNY', + ruleSchemaVersion: initial?.ruleSchemaVersion || 1, + sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106', + sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null, + remark: initial?.remark || '', + pricing: toFormPricing(billingMode, initial?.ruleJson || {}), + }); + }, [initial, form]); + + useEffect(() => { + if (!mode) return; + form.setFieldValue('calculatorVersion', calculatorByMode[mode]); + }, [mode, form]); + + const submit = async (values: any) => { + const billingMode = values.billingMode as ModelPricingBillingMode; + const [from, to] = values.effectiveRange || []; + const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {}; + await onSubmit({ + provider: values.provider, + model_name: values.modelName, + model_category: values.modelCategory, + billing_mode: billingMode, + calculator_version: calculatorByMode[billingMode], + version_code: values.versionCode, + effective_from: from.toISOString(), + effective_to: to ? to.toISOString() : null, + currency: values.currency || 'CNY', + rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1), + rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}), + source_url: values.sourceUrl || null, + source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null, + remark: values.remark || null, + }); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + 价格参数 + {mode === 'text_token_tiered' && <> + + + + + {(fields, { add, remove }) => <> + {fields.map(field => ( + + + + + + + + + + ))} + + } + + } + + {mode === 'image_per_output' && + + + + + + + {(fields, { add, remove }) => <> + {fields.map(field => + + + + )} + + } + + } + + {mode === 'video_token_rate' && <> + + + + + + 分辨率 + 比例对应真实像素 + + {(fields, { add, remove }) => <> + {fields.map(field => + + + + + + )} + + } + + 视频价格档位 + + {(fields, { add, remove }) => <> + {fields.map(field =>
+ + + + + + + +
)} + + } +
+ } + + 来源与备注 + + + + + + + + + ); +}; + +export default PricingRuleForm; diff --git a/video-gen-admin/src/components/modelPricing/PricingRulePreview.tsx b/video-gen-admin/src/components/modelPricing/PricingRulePreview.tsx new file mode 100644 index 00000000..79dcf8e0 --- /dev/null +++ b/video-gen-admin/src/components/modelPricing/PricingRulePreview.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Card, Input, message, Space, Typography } from 'antd'; +import { previewModelPricing } from '../../api'; +import type { + ModelPricingBillingMode, + ModelPricingCalculatorVersion, + ModelPricingPreviewResponse, +} from '../../types'; + +interface Props { + billingMode: ModelPricingBillingMode; + calculatorVersion: ModelPricingCalculatorVersion; + ruleJson: Record; +} + +const examples: Record> = { + text_token_tiered: { + input_tokens: 1000, + output_tokens: 300, + cached_input_tokens: 0, + audio_input_tokens: 0, + usage_source: 'provider', + }, + image_per_output: { + requested_output_count: 1, + successful_output_count: 1, + provider_billed_count: 1, + usage_source: 'provider_response', + }, + image_input_output_tiered: { + provider_input_image_count: 2, + requested_output_count: 1, + successful_output_count: 1, + output_items: [{ width: 2048, height: 2048, pixels: 4194304 }], + usage_source: 'provider_response', + }, + video_token_rate: { + total_tokens: 1000000, + resolution: '720p', + aspect_ratio: '9:16', + has_input_video: false, + generate_audio: false, + inference_mode: 'online', + usage_source: 'provider', + }, +}; + +function readAlias(source: Record, snakeKey: string, camelKey: string): any { + if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey]; + return source[camelKey]; +} + +function removeKeys(source: Record, keys: string[]): Record { + const result = { ...source }; + keys.forEach(key => delete result[key]); + return result; +} + +function normalizeTextTier(value: Record): Record { + const result = removeKeys(value || {}, [ + 'max_context_tokens', 'maxContextTokens', + 'input_rate', 'inputRate', + 'audio_input_rate', 'audioInputRate', + 'output_rate', 'outputRate', + 'cached_input_rate', 'cachedInputRate', + 'cached_audio_input_rate', 'cachedAudioInputRate', + ]); + + const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens'); + return { + ...result, + max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens, + input_rate: readAlias(value || {}, 'input_rate', 'inputRate'), + audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'), + output_rate: readAlias(value || {}, 'output_rate', 'outputRate'), + cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'), + cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'), + }; +} + +function normalizeOutputTier(value: Record): Record { + const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']); + const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels'); + return { + ...result, + max_pixels: maxPixels === undefined ? null : maxPixels, + rate: value?.rate, + }; +} + +function normalizeVideoRate(value: Record): Record { + const result = removeKeys(value || {}, [ + 'has_input_video', 'hasInputVideo', + 'generate_audio', 'generateAudio', + 'inference_modes', 'inferenceModes', + ]); + + const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo'); + const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio'); + const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes'); + + return { + ...result, + ...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }), + ...(generateAudio === undefined ? {} : { generate_audio: generateAudio }), + ...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }), + }; +} + +/** + * API 返回层可能会把嵌套 rule_json 一并转成 camelCase。 + * 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。 + */ +function normalizeRuleJson( + billingMode: ModelPricingBillingMode, + value: Record, +): Record { + const source = value || {}; + const base = removeKeys(source, [ + 'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour', + 'output_rate', 'outputRate', + 'bill_by', 'billBy', + 'free_input_images', 'freeInputImages', + 'input_image_rate', 'inputImageRate', + 'output_tiers', 'outputTiers', + 'token_formula', 'tokenFormula', + 'default_fps', 'defaultFps', + 'supported_resolutions', 'supportedResolutions', + 'dimension_map', 'dimensionMap', + ]); + + if (billingMode === 'text_token_tiered') { + const tiers = Array.isArray(source.tiers) ? source.tiers : []; + return { + ...base, + cache_storage_rate_per_million_token_hour: readAlias( + source, + 'cache_storage_rate_per_million_token_hour', + 'cacheStorageRatePerMillionTokenHour', + ), + tiers: tiers.map(item => normalizeTextTier(item || {})), + }; + } + + if (billingMode === 'image_per_output') { + return { + ...base, + output_rate: readAlias(source, 'output_rate', 'outputRate'), + bill_by: readAlias(source, 'bill_by', 'billBy'), + }; + } + + if (billingMode === 'image_input_output_tiered') { + const outputTiers = readAlias(source, 'output_tiers', 'outputTiers'); + return { + ...base, + free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'), + input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'), + output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})), + bill_by: readAlias(source, 'bill_by', 'billBy'), + }; + } + + return { + ...base, + token_formula: readAlias(source, 'token_formula', 'tokenFormula'), + default_fps: readAlias(source, 'default_fps', 'defaultFps'), + supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'), + dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {}, + rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})), + }; +} + +const PricingRulePreview: React.FC = ({ billingMode, calculatorVersion, ruleJson }) => { + const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]); + const [usageText, setUsageText] = useState(initial); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setUsageText(initial); + setResult(null); + }, [initial, ruleJson, calculatorVersion]); + + const run = async () => { + setLoading(true); + try { + const usage = JSON.parse(usageText); + setResult(await previewModelPricing({ + billing_mode: billingMode, + calculator_version: calculatorVersion, + rule_json: normalizeRuleJson(billingMode, ruleJson), + usage, + currency: 'CNY', + })); + } catch (e: any) { + message.error(e?.message || '计价试算失败'); + } finally { + setLoading(false); + } + }; + + return + + 计算器:{calculatorVersion}。图片使用同步响应中的实际输出条目;视频优先使用供应商实际 Token。 + + setUsageText(e.target.value)} + rows={10} + style={{ marginTop: 10, fontFamily: 'monospace' }} + /> + + + + {result && +
用量来源:{result.usageSource || '-'}
+
{JSON.stringify(result.breakdown, null, 2)}
+ } + />} +
; +}; + +export default PricingRulePreview; diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx index 2c3c3349..3e568458 100644 --- a/video-gen-admin/src/pages/AdminCreditRecords.tsx +++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { - Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography, + Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography, } from 'antd'; import { - ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined, + ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined, } from '@ant-design/icons'; -import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport'; import dayjs from 'dayjs'; +import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport'; import { getCreditRecords, getTeamOptions } from '../api'; import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types'; import { formatDate } from '../utils/formatDate'; @@ -14,21 +14,15 @@ import { formatDate } from '../utils/formatDate'; const TEAM_UNASSIGNED_VALUE = '__none__'; const DEFAULT_SUMMARY: AdminCreditRecordSummary = { - totalRecharge: 0, - totalConsume: 0, - totalRefund: 0, - transactionCount: 0, - generationCount: 0, - generationAttemptCount: 0, - imageGenerationCount: 0, - videoGenerationCount: 0, - imageConsume: 0, - videoConsume: 0, - textConsume: 0, - analysisConsume: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, + totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0, + generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0, + imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0, + totalTokens: 0, inputTokens: 0, outputTokens: 0, + attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0, + generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0, + providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000', + providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000', + providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0, }; const RECORD_TYPE_MAP: Record = { @@ -39,73 +33,38 @@ const RECORD_TYPE_MAP: Record { if (scope === 'admin') return { userType: 'admin' }; if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' }; if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' }; return {}; } +function costStatusColor(status?: string): string { + if (status === 'calculated') return 'green'; + if (status === 'estimated') return 'orange'; + if (status === 'pending') return 'blue'; + if (status === 'not_applicable') return 'default'; + return 'red'; +} + +const JsonBlock: React.FC<{ value?: Record | null }> = ({ value }) => ( +
+    {value ? JSON.stringify(value, null, 2) : '-'}
+  
+); const AdminCreditRecords: React.FC = () => { const [records, setRecords] = useState([]); @@ -163,6 +143,7 @@ const AdminCreditRecords: React.FC = () => { const [exportProgress, setExportProgress] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); + const [detail, setDetail] = useState(null); const [userScope, setUserScope] = useState(''); const [teamFilter, setTeamFilter] = useState(''); @@ -175,24 +156,24 @@ const AdminCreditRecords: React.FC = () => { const [sourceStepCode, setSourceStepCode] = useState(''); const [billingScene, setBillingScene] = useState(''); const [userNameFilter, setUserNameFilter] = useState(''); + const [engineProvider, setEngineProvider] = useState(''); + const [engineModelName, setEngineModelName] = useState(''); + const [pricingVersionCode, setPricingVersionCode] = useState(''); + const [providerCostStatus, setProviderCostStatus] = useState(''); + const [hasAttachment, setHasAttachment] = useState(''); const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); const query = useMemo(() => ({ - page, - pageSize, - userName: userNameFilter || undefined, - teamId: teamFilter || undefined, - recordType: recordType || undefined, - creditSubject: creditSubject || undefined, - mediaType: mediaType || undefined, - chargeKind: chargeKind || undefined, - sourceModule: sourceModule || undefined, - sourceStepCode: sourceStepCode || undefined, + page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined, + recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined, + chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined, billingScene: billingScene || undefined, - startDate: dateRange[0]?.format('YYYY-MM-DD'), - endDate: dateRange[1]?.format('YYYY-MM-DD'), + engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined, + pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined, + hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true', + 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, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]); const load = async () => { setLoading(true); @@ -200,33 +181,20 @@ const AdminCreditRecords: React.FC = () => { const res = await getCreditRecords(query); setRecords(res.items || []); setTotal(res.total || 0); - setSummary(res.summary || DEFAULT_SUMMARY); + setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) }); } catch (e: any) { message.error(e?.message || '加载积分记录失败'); } finally { setLoading(false); } }; - useEffect(() => { load(); }, [query]); - - useEffect(() => { - getTeamOptions(true).then(setTeamOptions).catch(() => {}); - }, []); + useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []); const handleReset = () => { - setUserScope(''); - setTeamFilter(''); - setRecordType(''); - setCreditSubject(''); - setMediaType(''); - setChargeKind(''); - setSourceModule(''); - setSourceStepCode(''); - setBillingScene(''); - setUserNameFilter(''); - setDateRange([null, null]); - setPage(1); + setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind(''); + setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName(''); + setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1); }; const exportExcel = async () => { @@ -237,168 +205,179 @@ const AdminCreditRecords: React.FC = () => { const baseQuery = { ...query, page: 1, pageSize: exportPageSize }; const first = await getCreditRecords(baseQuery); const all: AdminCreditRecord[] = [...(first.items || [])]; - const exportSummary = first.summary || DEFAULT_SUMMARY; + const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) }; const totalRows = first.total || 0; const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize)); - setExportProgress(`正在获取 ${all.length} / ${totalRows}`); for (let p = 2; p <= totalPages; p += 1) { const res = await getCreditRecords({ ...baseQuery, page: p }); all.push(...(res.items || [])); setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`); } - - const detailColumns: StyledExcelColumn[] = [ - { title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') }, - { title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' }, - { title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' }, - { title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' }, - { 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: 20, render: (r) => r.creditSubjectLabel || '-' }, - { title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' }, - { title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' }, - { title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' }, - { title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' }, - { title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' }, - { title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount }, - { title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter }, - { title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 }, - { title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 }, - { title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 }, - { title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) }, - { title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' }, - { title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' }, - { title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' }, - { title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' }, - { title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' }, - { title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' }, - { title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' }, - { title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' }, + const columns: StyledExcelColumn[] = [ + { title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') }, + { title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' }, + { title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' }, + { title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' }, + { title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' }, + { title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' }, + { title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' }, + { title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' }, + { title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' }, + { title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter }, + { title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 }, + { title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 }, + { title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 }, + { title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 }, + { title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) }, + { title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) }, + { title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 }, + { title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 }, + { title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' }, + { title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' }, + { title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' }, + { title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') }, + { title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) }, + { title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' }, + { title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '否' }, + { title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') }, + { title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '否' }, + { title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) }, + { title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' }, + { title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' }, + { title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' }, + { title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' }, ]; - - const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`; - exportStyledExcel({ - filename, - sheetName: '积分流水', - title: '积分流水汇总', - metadataRows: [ - ['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], - ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], - ['导出条数', totalRows], - ], + exportStyledExcel({ + filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查', + metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]], summaryRows: [ - ['总充值', exportSummary.totalRecharge], - ['总消费', exportSummary.totalConsume], - ['总回退', exportSummary.totalRefund], - ['交易笔数', exportSummary.transactionCount], - ['生成条数', exportSummary.generationCount], - ['生成尝试次数', exportSummary.generationAttemptCount], - ['图片生成条数', exportSummary.imageGenerationCount], - ['视频生成条数', exportSummary.videoGenerationCount], - ['图片消费积分', exportSummary.imageConsume], - ['视频消费积分', exportSummary.videoConsume], - ['提词消费积分', exportSummary.textConsume], - ['视频分析积分', exportSummary.analysisConsume], - ['总 Token', exportSummary.totalTokens], - ['输入 Token', exportSummary.inputTokens], - ['输出 Token', exportSummary.outputTokens], - ], - columns: detailColumns, - rows: all, + ['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount], + ['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount], + ['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount], + ['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume], + ['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume], + ['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens], + ['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount], + ['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount], + ['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)], + ['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)], + ['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)], + ['待核算流水数', exportSummary.providerCostPendingCount], + ['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount], + ], columns, rows: all, }); message.success('Excel 已导出'); } catch (e: any) { message.error(e?.message || '导出失败'); - } finally { - setExporting(false); - setExportProgress(''); - } + } finally { setExporting(false); setExportProgress(''); } }; const columns = [ { title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) =>
{v || '-'}
{r.phone || '-'}
}, - { title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => {r.userTypeLabel || '-'} }, + { title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) =>
{r.userTypeLabel || '-'}
{r.frontendUserKindLabel || '-'}
}, { 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: 'creditSubjectLabel', width: 150, render: (v: string) => {v || '-'} }, - { title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' }, - { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
}, - { title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? {v} : '-' }, - { title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)} }, - { title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) }, + { title: '流水类型', dataIndex: 'recordType', width: 105, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return {cfg.text}; } }, + { title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) =>
{v || '-'}
{r.chargeKindLabel || '-'}
}, + { title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' }, + { title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) =>
{r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
}, + { title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, render: (v: string) => v ? {v} : '-' }, + { title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v, 2)} }, + { title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) }, { title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) =>
{n(r.totalTokens)}
入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}
}, - { title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) =>
{engineTypeLabel(r.engineType)}
{r.engineName || '-'}
{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}
}, - { title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => {v ? '已删除' : '正常'} }, + { title: '执行配置', key: 'engine', width: 240, render: (_: any, r: AdminCreditRecord) =>
{engineTypeLabel(r.engineType)}
{r.engineName || '-'}
{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}
}, + { title: '关联状态', dataIndex: 'ownerDeleted', width: 105, render: (v: boolean) => {v ? '已删除' : '正常'} }, { title: '说明', dataIndex: 'description', width: 240, ellipsis: true }, - { title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => {formatDate(v)} }, + { title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => {formatDate(v)} }, + { title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) =>
图 {n(r.attachmentImageCount)} / 视 {n(r.attachmentVideoCount)} / 音 {n(r.attachmentAudioCount)}
合计 {n(r.attachmentTotalCount)}
}, + { title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) =>
图 {n(r.generatedImageCount)} / 视 {n(r.generatedVideoCount)}
请求 {n(r.requestedOutputCount)} / 实际 {n(r.generatedTotalCount)}
}, + { title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) =>
{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}
{r.providerCostStatusLabel || '-'}{r.providerCostIsEstimated && 估算}
}, + { title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) =>
{r.pricingVersionCode || '未锁价'}
{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}
}, + { title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => }, ]; - return ( -
-
-
总充值
+{n(summary.totalRecharge)}
-
总消费
-{n(summary.totalConsume)}
-
总回退
+{n(summary.totalRefund)}
-
交易 / 生成
{n(summary.transactionCount)} / {n(summary.generationCount)}
-
- -
- 图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分 - 视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分 - 提词消费:{n(summary.textConsume)} 积分 - 视频分析:{n(summary.analysisConsume)} 积分 -
- - -
- - { setPage(1); setTeamFilter(v); }} - style={{ width: 170 }} - options={[ - { value: '', label: '全部团队' }, - { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, - ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })), - ]} - /> - { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} /> - { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} /> - { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} /> - { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear /> - { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} /> - - - - - - -
- {exportProgress &&
{exportProgress}
} - { setPage(p); setPageSize(ps); }, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条记录`, - }} - scroll={{ x: 2050 }} - /> - + return
+
+
总充值
+{n(summary.totalRecharge, 2)}
+
总消费
-{n(summary.totalConsume, 2)}
+
总回退
+{n(summary.totalRefund, 2)}
+
交易 / 生成
{n(summary.transactionCount)} / {n(summary.generationCount)}
- ); +
+ 图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume, 2)} 积分 + 视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume, 2)} 积分 + 提词消费:{n(summary.textConsume, 2)} 积分 + 视频分析:{n(summary.analysisConsume, 2)} 积分 +
+
+ 输入附件(图/视/音):{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)} + 实际产出(图/视):{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)} + 已核算成本:¥{n(summary.providerCostCalculatedTotal, 8)} + 估算成本:{n(summary.providerCostEstimatedCount)} 条 / ¥{n(summary.providerCostEstimatedTotal, 8)} +
+
+ 总 Token:{n(summary.totalTokens)} + 待核算:{n(summary.providerCostPendingCount)} 条 + 异常成本:{n(summary.providerCostAbnormalCount)} 条 + 成本参考合计:¥{n(summary.providerCostCombinedTotal, 8)} +
+ + +
+ + { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} /> + { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} /> + { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} /> + { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} /> + { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} /> + { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear /> + { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear /> + { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear /> + { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear /> + { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} /> + + +
+ {exportProgress &&
{exportProgress}
} +
{ setPage(p); setPageSize(ps); }, showTotal: t => `共 ${t} 条` }} /> + + + setDetail(null)}> + {detail && <> + + {detail.id} + {detail.username || '-'} / {detail.phone || '-'} + {formatDate(detail.createdAt || '')} + {n(detail.amount, 2)} + {n(detail.balanceAfter, 2)} + {detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'} + {detail.pricingVersionCode || '-'} + {detail.pricingBillingModeLabel || '-'} + {detail.pricingCalculatorVersion || '-'} + {detail.pricingUsageSource || '-'} + {formatDate(detail.pricingReferenceAt || '')} + {formatDate(detail.pricingEffectiveFrom || '')} ~ {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'} + {detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)} + {detail.providerCostStatusLabel || '-'} + {formatDate(detail.providerCostFinalizedAt || '')} + {detail.providerUsagePrimary ? '是' : '否'} + 图 {detail.attachmentImageCount} / 视 {detail.attachmentVideoCount} / 音 {detail.attachmentAudioCount} + 请求 {detail.requestedOutputCount} / 图 {detail.generatedImageCount} / 视 {detail.generatedVideoCount} + 入 {detail.inputTokens} / 出 {detail.outputTokens} / 总 {detail.totalTokens} + {detail.ownerType || '-'} / {detail.ownerId || '-'} + + 计价快照 + 标准化用量 + 附件快照 + 生成快照 + } + + ; }; export default AdminCreditRecords; diff --git a/video-gen-admin/src/pages/AdminModelPricingRules.tsx b/video-gen-admin/src/pages/AdminModelPricingRules.tsx new file mode 100644 index 00000000..9daee995 --- /dev/null +++ b/video-gen-admin/src/pages/AdminModelPricingRules.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Card, Drawer, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd'; +import { CopyOutlined, EyeOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { + createModelPricingRule, + disableModelPricingRule, + getModelPricingRules, + publishModelPricingRule, + updateModelPricingRule, +} from '../api'; +import type { ModelPricingRule, ModelPricingRulePayload } from '../types'; +import PricingRuleForm from '../components/modelPricing/PricingRuleForm'; +import PricingRulePreview from '../components/modelPricing/PricingRulePreview'; +import { formatDate } from '../utils/formatDate'; + +const statusMap: Record = { + draft: { color: 'default', text: '草稿' }, + published: { color: 'green', text: '已发布' }, + disabled: { color: 'red', text: '已停用' }, +}; + +const modeMap: Record = { + text_token_tiered: '文本分档 Token', + image_per_output: '按成功输出图片', + image_input_output_tiered: '输入图 + 输出像素', + video_token_rate: '视频 Token', +}; + +const AdminModelPricingRules: React.FC = () => { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [modelName, setModelName] = useState(''); + const [status, setStatus] = useState(''); + const [category, setCategory] = useState(''); + const [editing, setEditing] = useState(null); + const [formOpen, setFormOpen] = useState(false); + const [detail, setDetail] = useState(null); + + const load = async () => { + setLoading(true); + try { + const res = await getModelPricingRules({ + page, + pageSize, + modelName: modelName || undefined, + publishStatus: status || undefined, + modelCategory: category || undefined, + }); + setRows(res.items || []); + setTotal(res.total || 0); + } catch (e: any) { + message.error(e?.message || '加载模型计价规则失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(); }, [page, pageSize, modelName, status, category]); + + const submit = async (payload: ModelPricingRulePayload) => { + setSaving(true); + try { + if (editing?.id && editing.publishStatus === 'draft') { + await updateModelPricingRule(editing.id, payload); + } else { + await createModelPricingRule(payload); + } + message.success('价格草稿已保存'); + setFormOpen(false); + setEditing(null); + await load(); + } catch (e: any) { + message.error(e?.message || '保存失败'); + } finally { + setSaving(false); + } + }; + + const cloneRule = (rule: ModelPricingRule) => { + setEditing({ + ...rule, + id: '', + publishStatus: 'draft', + versionCode: `${rule.versionCode}_copy_${dayjs().format('YYYYMMDDHHmm')}`, + effectiveFrom: dayjs().add(1, 'minute').toISOString(), + effectiveTo: null, + referencedCount: 0, + }); + setFormOpen(true); + }; + + const columns = [ + { title: '模型', dataIndex: 'modelName', width: 280, fixed: 'left' as const, render: (v: string, r: ModelPricingRule) =>
{v}
{r.provider} / {r.modelCategory}
}, + { title: '价格版本', dataIndex: 'versionCode', width: 180 }, + { title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) =>
{modeMap[r.billingMode] || r.billingMode}
{r.calculatorVersion}
}, + { title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) =>
{formatDate(r.effectiveFrom)}
至 {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}
}, + { title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => {(statusMap[v] || {}).text || v} }, + { title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) =>
{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}…` : '-'}
{r.referencedCount || 0} 条引用
}, + { title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' }, + { title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => + + {r.publishStatus === 'draft' && } + + {r.publishStatus === 'draft' && { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}>} + {r.publishStatus === 'published' && { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}>} + }, + ]; + + return
+ +
+ + { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} /> + { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} /> + + + + + +
+
{ setPage(p); setPageSize(ps); } }} /> + + + { setFormOpen(false); setEditing(null); }}> + { setFormOpen(false); setEditing(null); }} /> + + + setDetail(null)}> + {detail && <> + {detail.provider}{detail.modelCategory}{modeMap[detail.billingMode] || detail.billingMode}{(statusMap[detail.publishStatus] || {}).text} + 生效:{formatDate(detail.effectiveFrom)} ~ {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}
计算器:{detail.calculatorVersion}
规则 Hash:{detail.ruleContentHash || '-'}
+ 来源:{detail.sourceUrl || '-'}
官方更新时间:{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}
+
{JSON.stringify(detail.ruleJson, null, 2)}
+ + } +
+ ; +}; + +export default AdminModelPricingRules; diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index ca5682b4..6c7aab13 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -796,6 +796,20 @@ export interface AdminCreditRecordSummary { totalTokens: number; inputTokens: number; outputTokens: number; + attachmentImageCount: number; + attachmentVideoCount: number; + attachmentAudioCount: number; + attachmentTotalCount: number; + generatedImageCount: number; + generatedVideoCount: number; + generatedTotalCount: number; + providerCostCalculatedTotal: string; + providerCostEstimatedTotal: string; + providerCostCombinedTotal: string; + providerCostTotal: string; + providerCostPendingCount: number; + providerCostEstimatedCount: number; + providerCostAbnormalCount: number; } export interface AdminCreditRecord { @@ -848,6 +862,38 @@ export interface AdminCreditRecord { engineName?: string; engineProvider?: string; engineModelName?: string; + pricingRuleId?: string; + pricingVersionCode?: string; + pricingBillingMode?: string; + pricingBillingModeLabel?: string; + pricingCalculatorVersion?: ModelPricingCalculatorVersion; + pricingUsageSource?: string; + pricingReferenceAt?: string; + pricingEffectiveFrom?: string; + pricingEffectiveTo?: string; + pricingSnapshotHash?: string; + providerCostCurrency?: string; + providerCostAmount?: string; + providerCostStatus?: string; + providerCostStatusLabel?: string; + providerCostCalculatedAt?: string; + providerCostFinalizedAt?: string; + providerCostIsEstimated?: boolean; + providerUsagePrimary?: boolean; + attachmentImageCount: number; + attachmentVideoCount: number; + attachmentAudioCount: number; + attachmentTotalCount: number; + attachmentVideoDurationSeconds?: string; + attachmentAudioDurationSeconds?: string; + requestedOutputCount: number; + generatedImageCount: number; + generatedVideoCount: number; + generatedTotalCount: number; + pricingSnapshotJson?: Record | null; + usageSnapshotJson?: Record | null; + attachmentSnapshotJson?: Record | null; + generationSnapshotJson?: Record | null; createdAt?: string; } @@ -873,10 +919,83 @@ export interface AdminCreditRecordQueryParams { sourceModule?: string; sourceStepCode?: string; billingScene?: string; + engineProvider?: string; + engineModelName?: string; + pricingVersionCode?: string; + providerCostStatus?: string; + providerCostIsEstimated?: boolean; + hasAttachment?: boolean; startDate?: string; endDate?: string; } +export type ModelPricingCategory = 'text' | 'image' | 'video'; +export type ModelPricingBillingMode = + | 'text_token_tiered' + | 'image_per_output' + | 'image_input_output_tiered' + | 'video_token_rate'; +export type ModelPricingRuleStatus = 'draft' | 'published' | 'disabled'; +export type ModelPricingCalculatorVersion = + | 'text_token_tiered_v1' + | 'image_per_output_v1' + | 'image_input_output_tiered_v1' + | 'video_pixel_token_v1'; + +export interface ModelPricingRule { + id: string; + provider: string; + modelName: string; + modelCategory: ModelPricingCategory; + billingMode: ModelPricingBillingMode; + calculatorVersion: ModelPricingCalculatorVersion; + versionCode: string; + effectiveFrom: string; + effectiveTo?: string | null; + publishStatus: ModelPricingRuleStatus; + currency: string; + ruleSchemaVersion: number; + ruleContentHash: string; + ruleJson: Record; + sourceUrl?: string | null; + sourceUpdatedAt?: string | null; + remark?: string | null; + referencedCount: number; + createdAt?: string; + updatedAt?: string; +} + +export interface ModelPricingRulePayload { + provider: string; + model_name: string; + model_category: ModelPricingCategory; + billing_mode: ModelPricingBillingMode; + calculator_version: ModelPricingCalculatorVersion; + version_code: string; + effective_from: string; + effective_to?: string | null; + currency: string; + rule_schema_version: number; + rule_json: Record; + source_url?: string | null; + source_updated_at?: string | null; + remark?: string | null; +} + +export interface ModelPricingRuleListResponse { + items: ModelPricingRule[]; + total: number; +} + +export interface ModelPricingPreviewResponse { + amount: string; + currency: string; + isEstimated: boolean; + selectedRate?: string | null; + usageSource: string; + breakdown: Record; +} + // ── 首页素材行业装修 ────────────────────────────────────── diff --git a/video-gen-api/alembic/versions/1c93b40133f0_add_model_pricing_rules_and_credit_.py b/video-gen-api/alembic/versions/1c93b40133f0_add_model_pricing_rules_and_credit_.py new file mode 100644 index 00000000..b8274522 --- /dev/null +++ b/video-gen-api/alembic/versions/1c93b40133f0_add_model_pricing_rules_and_credit_.py @@ -0,0 +1,582 @@ +"""add model pricing rules and credit pricing snapshots + +Revision ID: 1c93b40133f0 +Revises: 2026070902 +Create Date: 2026-07-10 15:10:44.983521 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "1c93b40133f0" +down_revision: Union[str, None] = "2026070902" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +FK_CREDIT_RECORD_PRICING_RULE = ( + "fk_credit_records_pricing_rule_id_model_pricing_rules" +) + + +def _json_type() -> sa.types.TypeEngine: + """Use JSONB on PostgreSQL and JSON on other supported development DBs.""" + return sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), + "postgresql", + ) + + +def upgrade() -> None: + # 1. Versioned model pricing rules. + op.create_table( + "model_pricing_rules", + sa.Column("id", sa.String(length=32), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("model_name", sa.String(length=128), nullable=False), + sa.Column("model_category", sa.String(length=16), nullable=False), + sa.Column("billing_mode", sa.String(length=48), nullable=False), + sa.Column("calculator_version", sa.String(length=64), nullable=False), + sa.Column("version_code", sa.String(length=64), nullable=False), + sa.Column("effective_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("effective_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("publish_status", sa.String(length=16), nullable=False), + sa.Column("currency", sa.String(length=8), nullable=False), + sa.Column("rule_schema_version", sa.Integer(), nullable=False), + sa.Column("rule_json", _json_type(), nullable=False), + sa.Column("rule_content_hash", sa.String(length=64), nullable=False), + sa.Column("source_url", sa.Text(), nullable=True), + sa.Column("source_updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("remark", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(length=32), nullable=True), + sa.Column("updated_by", sa.String(length=32), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + # A disabled future rule is represented by an empty interval where + # effective_to == effective_from, so equality must be allowed. + sa.CheckConstraint( + "effective_to IS NULL OR effective_to >= effective_from", + name="ck_model_pricing_rules_effective_range", + ), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_index( + "uq_model_pricing_rules_provider_model_version", + "model_pricing_rules", + ["provider", "model_name", "version_code"], + unique=True, + ) + op.create_index( + "ix_model_pricing_rules_resolve", + "model_pricing_rules", + [ + "provider", + "model_name", + "publish_status", + "effective_from", + "effective_to", + ], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_category_status", + "model_pricing_rules", + ["model_category", "publish_status"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_provider", + "model_pricing_rules", + ["provider"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_model_name", + "model_pricing_rules", + ["model_name"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_model_category", + "model_pricing_rules", + ["model_category"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_billing_mode", + "model_pricing_rules", + ["billing_mode"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_calculator_version", + "model_pricing_rules", + ["calculator_version"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_effective_from", + "model_pricing_rules", + ["effective_from"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_effective_to", + "model_pricing_rules", + ["effective_to"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_publish_status", + "model_pricing_rules", + ["publish_status"], + unique=False, + ) + op.create_index( + "ix_model_pricing_rules_rule_content_hash", + "model_pricing_rules", + ["rule_content_hash"], + unique=False, + ) + + # 2. Bind provider callbacks to the exact billing attempt. + op.add_column( + "chat_generation_tasks", + sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True), + ) + op.create_index( + "ix_chat_generation_tasks_current_billing_attempt_no", + "chat_generation_tasks", + ["current_billing_attempt_no"], + unique=False, + ) + + # 3. Immutable pricing, usage, attachment, and output snapshots. + op.add_column( + "credit_records", + sa.Column("pricing_rule_id", sa.String(length=32), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_version_code", sa.String(length=64), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_billing_mode", sa.String(length=48), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column( + "pricing_calculator_version", + sa.String(length=64), + nullable=True, + ), + ) + op.add_column( + "credit_records", + sa.Column("pricing_usage_source", sa.String(length=32), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_reference_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_effective_from", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_effective_to", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_snapshot_schema_version", sa.Integer(), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("pricing_snapshot_hash", sa.String(length=64), nullable=True), + ) + + op.add_column( + "credit_records", + sa.Column("provider_cost_currency", sa.String(length=8), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column( + "provider_cost_amount", + sa.Numeric(precision=20, scale=8), + nullable=True, + ), + ) + op.add_column( + "credit_records", + sa.Column("provider_cost_status", sa.String(length=32), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column( + "provider_cost_calculated_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "provider_cost_finalized_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "provider_usage_primary", + sa.Boolean(), + server_default=sa.text("true"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "provider_cost_is_estimated", + sa.Boolean(), + server_default=sa.text("false"), + nullable=False, + ), + ) + + op.add_column( + "credit_records", + sa.Column( + "attachment_image_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "attachment_video_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "attachment_audio_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "attachment_total_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "attachment_video_duration_seconds", + sa.Numeric(precision=20, scale=6), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "attachment_audio_duration_seconds", + sa.Numeric(precision=20, scale=6), + server_default=sa.text("0"), + nullable=False, + ), + ) + + op.add_column( + "credit_records", + sa.Column( + "requested_output_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "generated_image_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "generated_video_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + op.add_column( + "credit_records", + sa.Column( + "generated_total_count", + sa.Integer(), + server_default=sa.text("0"), + nullable=False, + ), + ) + + op.add_column( + "credit_records", + sa.Column("pricing_snapshot_json", _json_type(), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("usage_snapshot_json", _json_type(), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("attachment_snapshot_json", _json_type(), nullable=True), + ) + op.add_column( + "credit_records", + sa.Column("generation_snapshot_json", _json_type(), nullable=True), + ) + + op.create_index( + "ix_credit_records_pricing_rule_id", + "credit_records", + ["pricing_rule_id"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_version_code", + "credit_records", + ["pricing_version_code"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_usage_source", + "credit_records", + ["pricing_usage_source"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_reference_at", + "credit_records", + ["pricing_reference_at"], + unique=False, + ) + op.create_index( + "ix_credit_records_provider_cost_status", + "credit_records", + ["provider_cost_status"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_status_time", + "credit_records", + ["provider_cost_status", "created_at"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_model_time", + "credit_records", + ["engine_provider", "engine_model_name", "pricing_reference_at"], + unique=False, + ) + op.create_index( + "ix_credit_records_pricing_version", + "credit_records", + ["pricing_version_code", "pricing_rule_id"], + unique=False, + ) + op.create_foreign_key( + FK_CREDIT_RECORD_PRICING_RULE, + "credit_records", + "model_pricing_rules", + ["pricing_rule_id"], + ["id"], + ondelete="RESTRICT", + ) + + # 4. Lock the engine and billing attempt for legacy project generations. + op.add_column( + "generation_records", + sa.Column("engine_id", sa.String(length=32), nullable=True), + ) + op.add_column( + "generation_records", + sa.Column("engine_snapshot_json", sa.Text(), nullable=True), + ) + op.add_column( + "generation_records", + sa.Column("provider_response_json", sa.Text(), nullable=True), + ) + op.add_column( + "generation_records", + sa.Column("current_billing_attempt_no", sa.Integer(), nullable=True), + ) + op.create_index( + "ix_generation_records_engine_id", + "generation_records", + ["engine_id"], + unique=False, + ) + op.create_index( + "ix_generation_records_current_billing_attempt_no", + "generation_records", + ["current_billing_attempt_no"], + unique=False, + ) + + +def downgrade() -> None: + # Reverse legacy generation record extensions. + op.drop_index( + "ix_generation_records_current_billing_attempt_no", + table_name="generation_records", + ) + op.drop_index( + "ix_generation_records_engine_id", + table_name="generation_records", + ) + op.drop_column("generation_records", "current_billing_attempt_no") + op.drop_column("generation_records", "provider_response_json") + op.drop_column("generation_records", "engine_snapshot_json") + op.drop_column("generation_records", "engine_id") + + # Reverse credit pricing snapshots before dropping the referenced rule table. + op.drop_constraint( + FK_CREDIT_RECORD_PRICING_RULE, + "credit_records", + type_="foreignkey", + ) + op.drop_index("ix_credit_records_pricing_version", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_model_time", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_status_time", table_name="credit_records") + op.drop_index("ix_credit_records_provider_cost_status", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_reference_at", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_usage_source", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_version_code", table_name="credit_records") + op.drop_index("ix_credit_records_pricing_rule_id", table_name="credit_records") + + op.drop_column("credit_records", "generation_snapshot_json") + op.drop_column("credit_records", "attachment_snapshot_json") + op.drop_column("credit_records", "usage_snapshot_json") + op.drop_column("credit_records", "pricing_snapshot_json") + op.drop_column("credit_records", "generated_total_count") + op.drop_column("credit_records", "generated_video_count") + op.drop_column("credit_records", "generated_image_count") + op.drop_column("credit_records", "requested_output_count") + op.drop_column("credit_records", "attachment_audio_duration_seconds") + op.drop_column("credit_records", "attachment_video_duration_seconds") + op.drop_column("credit_records", "attachment_total_count") + op.drop_column("credit_records", "attachment_audio_count") + op.drop_column("credit_records", "attachment_video_count") + op.drop_column("credit_records", "attachment_image_count") + op.drop_column("credit_records", "provider_cost_is_estimated") + op.drop_column("credit_records", "provider_usage_primary") + op.drop_column("credit_records", "provider_cost_finalized_at") + op.drop_column("credit_records", "provider_cost_calculated_at") + op.drop_column("credit_records", "provider_cost_status") + op.drop_column("credit_records", "provider_cost_amount") + op.drop_column("credit_records", "provider_cost_currency") + op.drop_column("credit_records", "pricing_snapshot_hash") + op.drop_column("credit_records", "pricing_snapshot_schema_version") + op.drop_column("credit_records", "pricing_effective_to") + op.drop_column("credit_records", "pricing_effective_from") + op.drop_column("credit_records", "pricing_reference_at") + op.drop_column("credit_records", "pricing_usage_source") + op.drop_column("credit_records", "pricing_calculator_version") + op.drop_column("credit_records", "pricing_billing_mode") + op.drop_column("credit_records", "pricing_version_code") + op.drop_column("credit_records", "pricing_rule_id") + + # Reverse exact billing-attempt binding. + op.drop_index( + "ix_chat_generation_tasks_current_billing_attempt_no", + table_name="chat_generation_tasks", + ) + op.drop_column("chat_generation_tasks", "current_billing_attempt_no") + + # Reverse pricing-rule storage. + op.drop_index( + "ix_model_pricing_rules_rule_content_hash", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_publish_status", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_effective_to", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_effective_from", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_calculator_version", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_billing_mode", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_model_category", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_model_name", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_provider", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_category_status", + table_name="model_pricing_rules", + ) + op.drop_index( + "ix_model_pricing_rules_resolve", + table_name="model_pricing_rules", + ) + op.drop_index( + "uq_model_pricing_rules_provider_model_version", + table_name="model_pricing_rules", + ) + op.drop_table("model_pricing_rules") \ No newline at end of file diff --git a/video-gen-api/app/api/admin/__init__.py b/video-gen-api/app/api/admin/__init__.py index 359841ab..2b311187 100644 --- a/video-gen-api/app/api/admin/__init__.py +++ b/video-gen-api/app/api/admin/__init__.py @@ -8,6 +8,7 @@ from app.api.admin.private_portrait import router as private_portrait_router from app.api.admin.recharge_package import router as recharge_package_router from app.api.admin.menu_config import router as menu_config_router from app.api.admin.upload import router as admin_upload_router +from app.api.admin.model_pricing import router as model_pricing_router router = APIRouter() router.include_router(video_prompt_schema_config_router) @@ -18,3 +19,4 @@ router.include_router(private_portrait_router) router.include_router(recharge_package_router) router.include_router(menu_config_router) router.include_router(admin_upload_router) +router.include_router(model_pricing_router) diff --git a/video-gen-api/app/api/admin/model_pricing.py b/video-gen-api/app/api/admin/model_pricing.py new file mode 100644 index 00000000..5bbb1afe --- /dev/null +++ b/video-gen-api/app/api/admin/model_pricing.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_admin_user, get_db +from app.models.user import User +from app.schemas.model_pricing import ( + ModelPricingPreviewOut, + ModelPricingPreviewRequest, + ModelPricingRuleCreate, + ModelPricingRuleListOut, + ModelPricingRuleOut, + ModelPricingRuleUpdate, +) +from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing +from app.services.model_pricing.rule_service import ( + PricingRuleError, + create_rule, + disable_rule, + get_rule_snapshot, + list_rules, + publish_rule, + update_draft_rule, +) +from app.services.operation_log import log_operation +from app.services.operation_log_service import log_model_pricing_event + + +router = APIRouter(prefix="/admin/model-pricing", tags=["admin-model-pricing"]) + + +def _http_error(exc: Exception) -> HTTPException: + return HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/rules", response_model=ModelPricingRuleListOut) +async def admin_list_model_pricing_rules( + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=500), + provider: str | None = Query(None), + model_name: str | None = Query(None), + model_category: str | None = Query(None), + publish_status: str | None = Query(None), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + return await list_rules( + db, + page=page, + page_size=page_size, + provider=provider, + model_name=model_name, + model_category=model_category, + publish_status=publish_status, + ) + + +@router.get("/rules/{rule_id}", response_model=ModelPricingRuleOut) +async def admin_get_model_pricing_rule( + rule_id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + try: + snapshot = await get_rule_snapshot(db, rule_id) + return {**snapshot, "referenced_count": 0} + except PricingRuleError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/rules", response_model=ModelPricingRuleOut) +async def admin_create_model_pricing_rule( + req: ModelPricingRuleCreate, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + admin_id = str(admin.id) + admin_username = str(admin.username or "") + try: + snapshot = await create_rule(db, payload=req.model_dump(), operator_id=admin_id) + await log_operation( + db, + admin_id, + admin_username, + f"创建模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}", + "POST", + "/admin/model-pricing/rules", + detail=json.dumps(snapshot, ensure_ascii=False, default=str), + ) + await db.commit() + log_model_pricing_event( + event_type="pricing_rule_validate", + user_id=admin_id, + pricing_rule_id=snapshot["id"], + pricing_version=snapshot["version_code"], + provider=snapshot["provider"], + model_name=snapshot["model_name"], + billing_mode=snapshot["billing_mode"], + message="模型计价草稿创建成功", + ) + return {**snapshot, "referenced_count": 0} + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc + except PricingRuleError as exc: + await db.rollback() + raise _http_error(exc) from exc + + +@router.put("/rules/{rule_id}", response_model=ModelPricingRuleOut) +async def admin_update_model_pricing_rule( + rule_id: str, + req: ModelPricingRuleUpdate, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + admin_id = str(admin.id) + admin_username = str(admin.username or "") + try: + snapshot = await update_draft_rule( + db, + rule_id=rule_id, + payload=req.model_dump(exclude_unset=True), + operator_id=admin_id, + ) + await log_operation( + db, + admin_id, + admin_username, + f"更新模型计价草稿 {snapshot['model_name']}/{snapshot['version_code']}", + "PUT", + f"/admin/model-pricing/rules/{rule_id}", + detail=json.dumps(snapshot, ensure_ascii=False, default=str), + ) + await db.commit() + return {**snapshot, "referenced_count": 0} + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc + except PricingRuleError as exc: + await db.rollback() + raise _http_error(exc) from exc + + +@router.post("/rules/{rule_id}/publish", response_model=ModelPricingRuleOut) +async def admin_publish_model_pricing_rule( + rule_id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + admin_id = str(admin.id) + admin_username = str(admin.username or "") + try: + snapshot = await publish_rule(db, rule_id=rule_id, operator_id=admin_id) + await log_operation( + db, + admin_id, + admin_username, + f"发布模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}", + "POST", + f"/admin/model-pricing/rules/{rule_id}/publish", + detail=json.dumps(snapshot, ensure_ascii=False, default=str), + ) + await db.commit() + log_model_pricing_event( + event_type="pricing_rule_publish", + user_id=admin_id, + pricing_rule_id=snapshot["id"], + pricing_version=snapshot["version_code"], + provider=snapshot["provider"], + model_name=snapshot["model_name"], + billing_mode=snapshot["billing_mode"], + ) + return {**snapshot, "referenced_count": 0} + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc + except PricingRuleError as exc: + await db.rollback() + raise _http_error(exc) from exc + + +@router.post("/rules/{rule_id}/disable", response_model=ModelPricingRuleOut) +async def admin_disable_model_pricing_rule( + rule_id: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + admin_id = str(admin.id) + admin_username = str(admin.username or "") + try: + snapshot = await disable_rule(db, rule_id=rule_id, operator_id=admin_id) + await log_operation( + db, + admin_id, + admin_username, + f"停用模型计价版本 {snapshot['model_name']}/{snapshot['version_code']}", + "POST", + f"/admin/model-pricing/rules/{rule_id}/disable", + detail=json.dumps(snapshot, ensure_ascii=False, default=str), + ) + await db.commit() + return {**snapshot, "referenced_count": 0} + except IntegrityError as exc: + await db.rollback() + raise HTTPException(status_code=409, detail="模型计价版本或生效区间发生并发冲突") from exc + except PricingRuleError as exc: + await db.rollback() + raise _http_error(exc) from exc + + +@router.post("/preview", response_model=ModelPricingPreviewOut) +async def admin_preview_model_pricing( + req: ModelPricingPreviewRequest, + admin: User = Depends(get_admin_user), +): + try: + result = calculate_pricing( + billing_mode=req.billing_mode, + calculator_version=req.calculator_version, + rule_json=req.rule_json, + usage=req.usage, + currency=req.currency, + ) + return { + "amount": str(result.amount), + "currency": result.currency, + "is_estimated": result.is_estimated, + "selected_rate": str(result.selected_rate) if result.selected_rate is not None else None, + "usage_source": result.usage_source, + "breakdown": result.breakdown, + } + except PricingCalculationError as exc: + raise _http_error(exc) from exc diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index f37562c2..ff49c53d 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -37,6 +37,7 @@ from app.schemas.admin import ( ResetPasswordRequest, UpdateFrontendUserKindRequest, OperationLogOut, + AdminCreditRecordListOut, ) from app.schemas.team import UpdateUserTeamRequest from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut @@ -61,6 +62,7 @@ from app.services.generation_billing_service import ( get_next_credit_attempt_no, ) from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once +from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot from app.utils.id_gen import generate_id from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS @@ -481,7 +483,7 @@ async def admin_change_password( # ── Credit Records ─────────────────────────────────────── -@router.get("/credit-records") +@router.get("/credit-records", response_model=AdminCreditRecordListOut) async def list_credit_records( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=1000), @@ -498,6 +500,12 @@ async def list_credit_records( source_module: str | None = Query(None), source_step_code: str | None = Query(None), billing_scene: str | None = Query(None), + engine_provider: str | None = Query(None), + engine_model_name: str | None = Query(None), + pricing_version_code: str | None = Query(None), + provider_cost_status: str | None = Query(None), + provider_cost_is_estimated: bool | None = Query(None), + has_attachment: bool | None = Query(None), start_date: str = Query(None), end_date: str = Query(None), admin: User = Depends(get_admin_user), @@ -520,6 +528,12 @@ async def list_credit_records( source_module=source_module, source_step_code=source_step_code, billing_scene=billing_scene, + engine_provider=engine_provider, + engine_model_name=engine_model_name, + pricing_version_code=pricing_version_code, + provider_cost_status=provider_cost_status, + provider_cost_is_estimated=provider_cost_is_estimated, + has_attachment=has_attachment, start_date=start_date, end_date=end_date, ) @@ -2046,6 +2060,9 @@ async def admin_generate_video( raise HTTPException(status_code=400, detail="不支持的分辨率") duration = record.duration or 5 + from app.services.video_gen import get_active_engine, submit_video_task + engine = await get_active_engine(db) + engine_snapshot = _build_video_snapshot(engine, aspect_ratio, resolution, duration) media_billing = await charge_generation_media_by_params( db, user_id=record.user_id, @@ -2053,14 +2070,21 @@ async def admin_generate_video( gen_type="video", duration=duration, resolution=resolution, + aspect_ratio=aspect_ratio, + fps=24, + engine_id=engine.id, project_name=project_name, description_prefix="视频生成(管理后台)", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + media_references=record.media_references, ) record.aspect_ratio = aspect_ratio record.resolution = resolution + record.engine_id = engine.id + record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str) + record.current_billing_attempt_no = attempt_no record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.status = "generating" record.error_message = None @@ -2071,8 +2095,6 @@ async def admin_generate_video( await db.flush() try: - from app.services.video_gen import get_active_engine, submit_video_task - engine = await get_active_engine(db) task_id = await submit_video_task( db, engine, @@ -2095,19 +2117,31 @@ async def admin_generate_video( post_image_size = body.get("image_size", "") image_size = post_image_size or record.image_size or "2K" + from app.services.image_gen import get_active_image_engine + engine = await get_active_image_engine(db) + image_proportion = record.image_proportion or "1:1" + image_px = record.image_px or "2048x2048" + engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px) media_billing = await charge_generation_media_by_params( db, user_id=record.user_id, record_id=record.id, gen_type="image", image_size=image_size, + image_px=image_px, + aspect_ratio=image_proportion, + engine_id=engine.id, project_name=project_name, description_prefix="图片生成(管理后台)", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + media_references=record.media_references, ) record.image_size = image_size + record.engine_id = engine.id + record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str) + record.current_billing_attempt_no = attempt_no record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.status = "generating" record.error_message = None diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index 92fa81a4..d8941468 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -49,6 +49,7 @@ from app.services.generation_billing_service import ( ) from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot +from app.services.generation_ai_service import _build_image_snapshot, _build_video_snapshot from app.services.credit_record_meta_service import build_generation_record_prompt_meta from app.services.video_cover_service import async_create_video_cover_for_local_video from app.enums.audio_reference import ( @@ -431,6 +432,9 @@ async def generate( raise HTTPException(status_code=400, detail="不支持的分辨率") duration = record.duration or 5 + from app.services.video_gen import get_active_engine, submit_video_task + engine = await get_active_engine(db) + engine_snapshot = _build_video_snapshot(engine, req.aspect_ratio, req.resolution, duration) media_billing = await charge_generation_media_by_params( db, user_id=current_user.id, @@ -438,14 +442,21 @@ async def generate( gen_type="video", duration=duration, resolution=req.resolution, + aspect_ratio=req.aspect_ratio, + fps=24, + engine_id=engine.id, project_name=project_name, description_prefix=project_name+"-", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + media_references=record.media_references, ) record.aspect_ratio = req.aspect_ratio record.resolution = req.resolution + record.engine_id = engine.id + record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str) + record.current_billing_attempt_no = attempt_no record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.status = "generating" record.error_message = None @@ -456,11 +467,9 @@ async def generate( await db.flush() try: - from app.services.video_gen import get_active_engine, submit_video_task from app.services.error_codes import extract_error_message from app.services.video_queue import task_queue - engine = await get_active_engine(db) task_id = await submit_video_task( db, engine, @@ -480,19 +489,31 @@ async def generate( elif record.gen_type == GenerationType.image: image_size = req.image_size or record.image_size or "2K" + from app.services.image_gen import get_active_image_engine + engine = await get_active_image_engine(db) + image_proportion = record.image_proportion or "1:1" + image_px = record.image_px or "2048x2048" + engine_snapshot = _build_image_snapshot(engine, image_size, image_proportion, image_px) media_billing = await charge_generation_media_by_params( db, user_id=current_user.id, record_id=record.id, gen_type="image", image_size=image_size, + image_px=image_px, + aspect_ratio=image_proportion, + engine_id=engine.id, project_name=project_name, description_prefix=project_name+"-", owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + media_references=record.media_references, ) record.image_size = image_size + record.engine_id = engine.id + record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str) + record.current_billing_attempt_no = attempt_no record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.status = "generating" record.error_message = None @@ -549,14 +570,46 @@ async def retry_generation( owner_type=OWNER_GENERATION_RECORD, owner_id=record.id, ) - media_billing = await charge_generation_media_for_record( + if record.gen_type == GenerationType.video: + from app.services.video_gen import get_active_engine + engine = await get_active_engine(db) + engine_snapshot = _build_video_snapshot( + engine, + record.aspect_ratio or "16:9", + record.resolution or "720p", + record.duration or 5, + ) + else: + from app.services.image_gen import get_active_image_engine + engine = await get_active_image_engine(db) + engine_snapshot = _build_image_snapshot( + engine, + record.image_size or "2K", + record.image_proportion or "1:1", + record.image_px or "2048x2048", + ) + media_billing = await charge_generation_media_by_params( db, - record=record, + user_id=record.user_id, + record_id=record.id, + gen_type=record.gen_type, + image_size=record.image_size, + image_px=record.image_px, + aspect_ratio=record.aspect_ratio or record.image_proportion, + duration=record.duration, + resolution=record.resolution, + fps=24 if record.gen_type == GenerationType.video else None, + engine_id=engine.id, project_name=project_name, - description_prefix="视频重试", + description_prefix="生成重试-", + owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + media_references=record.media_references, ) + record.engine_id = engine.id + record.engine_snapshot_json = json.dumps(engine_snapshot, ensure_ascii=False, default=str) + record.current_billing_attempt_no = attempt_no record.status = "generating" record.error_message = None record.video_url = None @@ -570,8 +623,7 @@ async def retry_generation( try: from app.services.video_queue import task_queue if record.gen_type == GenerationType.video: - from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message - engine = await get_active_engine(db) + from app.services.video_gen import submit_video_task, extract_error_message task_id = await submit_video_task( db, engine, diff --git a/video-gen-api/app/api/v1/generation_ai.py b/video-gen-api/app/api/v1/generation_ai.py index 072848ee..5ecbbcd7 100644 --- a/video-gen-api/app/api/v1/generation_ai.py +++ b/video-gen-api/app/api/v1/generation_ai.py @@ -689,15 +689,20 @@ async def retry_task( record_id=task.id, gen_type=task.gen_type, image_size=task.image_size, + image_px=task.image_px, + aspect_ratio=task.aspect_ratio or task.image_proportion, duration=task.duration, resolution=task.resolution, + fps=24 if task.gen_type == "video" else None, engine_id=task.engine_id, project_name="AI生成任务", description_prefix="Chat任务重试", owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=attempt_no, + media_references=task.media_references, ) + task.current_billing_attempt_no = attempt_no task.status = "generating" task.pipeline_stage = "queued" task.error_message = None diff --git a/video-gen-api/app/cmd/backfill_credit_record_snapshots.py b/video-gen-api/app/cmd/backfill_credit_record_snapshots.py new file mode 100644 index 00000000..12c12559 --- /dev/null +++ b/video-gen-api/app/cmd/backfill_credit_record_snapshots.py @@ -0,0 +1,981 @@ +from __future__ import annotations + +import argparse +import asyncio +from copy import deepcopy +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any, Mapping + +from sqlalchemy import or_, select + +from app.enums.credit_record import ( + CreditRecordAction, + CreditRecordChargeKind, + CreditRecordOwnerType, + CreditRecordType, +) +from app.enums.model_pricing import ( + ModelPricingRuleStatus, + PricingSnapshotStage, + ProviderCostStatus, +) +from app.models.base import async_session +from app.models.chat_generation_task import ChatGenerationTask +from app.models.credit_record import CreditRecord +from app.models.generated_resource import GeneratedResource +from app.models.generation_record import GenerationRecord +from app.models.image_engine import ImageEngine +from app.models.model_config import ModelConfig +from app.models.model_pricing_rule import ModelPricingRule +from app.models.module_generation_project import ModuleGenerationProject +from app.models.module_generation_step import ModuleGenerationStep +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.models.token_usage import TokenUsage +from app.models.video_engine import VideoEngine +from app.services.model_pricing.attachment_snapshot_service import ( + build_attachment_snapshot, + build_generation_snapshot, +) +from app.services.model_pricing.rule_service import normalize_provider +from app.services.model_pricing.snapshot_service import finalize_credit_record_pricing +from app.services.model_pricing.usage_normalizer import ( + normalize_provider_media_usage, + parse_size, + safe_float, + safe_int, + safe_json_dict, +) +from app.services.operation_log_service import log_model_pricing_event +from app.services.resource_accounting_service import ( + SOURCE_MODEL_CHAT_TASK, + SOURCE_MODEL_GENERATION_RECORD, +) + + +PROCESSABLE_CHARGE_KINDS = { + CreditRecordChargeKind.MEDIA.value, + CreditRecordChargeKind.TEXT_PROMPT.value, + CreditRecordChargeKind.VIDEO_ANALYSIS.value, +} + +NON_PROVIDER_CHARGE_KINDS = { + CreditRecordChargeKind.FILE_PARSE.value, + CreditRecordChargeKind.VISION_INPUT.value, + CreditRecordChargeKind.MODULE_CREATE.value, + CreditRecordChargeKind.VIDEO_SPLIT.value, + CreditRecordChargeKind.RECHARGE.value, + CreditRecordChargeKind.REFUND.value, + CreditRecordChargeKind.ADMIN_ADJUST.value, + CreditRecordChargeKind.TEAM_INTERNAL.value, +} + +INCOMPLETE_COST_STATUSES = { + None, + "", + ProviderCostStatus.PENDING.value, + ProviderCostStatus.UNMATCHED_RULE.value, + ProviderCostStatus.USAGE_MISSING.value, + ProviderCostStatus.ERROR.value, + ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value, + ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value, + ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value, +} + + +class BackfillContext: + def __init__(self) -> None: + self.owner_maps: dict[str, dict[str, Any]] = {} + self.linked_chat_tasks: dict[str, ChatGenerationTask] = {} + self.token_usage_by_id: dict[str, TokenUsage] = {} + self.token_usage_by_owner: dict[tuple[str, str], TokenUsage] = {} + self.model_configs: dict[str, ModelConfig] = {} + self.image_engines: dict[str, ImageEngine] = {} + self.video_engines: dict[str, VideoEngine] = {} + self.resource_counts: dict[tuple[str, str], dict[str, int]] = {} + self.current_rules_by_category: dict[str, list[ModelPricingRule]] = {} + + +class BackfillStats: + def __init__(self) -> None: + self.scanned = 0 + self.changed = 0 + self.calculated = 0 + self.estimated = 0 + self.rule_bound = 0 + self.missing_engine = 0 + self.missing_usage = 0 + self.unmatched_rule = 0 + self.not_applicable = 0 + self.skipped_completed = 0 + self.skipped_non_provider = 0 + self.failed = 0 + self.force_repriced = 0 + + def merge(self, other: "BackfillStats") -> None: + for key in vars(self): + setattr(self, key, getattr(self, key) + getattr(other, key)) + + def as_dict(self) -> dict[str, int]: + return {key: int(value) for key, value in vars(self).items()} + + +def _parse_date(value: str | None, *, end: bool = False) -> datetime | None: + if not value: + return None + parsed = datetime.fromisoformat(value) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + if end and len(value) <= 10: + parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999) + return parsed + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _owner_key(record: CreditRecord) -> tuple[str, str] | None: + owner_type = str(record.owner_type or "").strip() + owner_id = str(record.owner_id or record.related_id or "").strip() + return (owner_type, owner_id) if owner_type and owner_id else None + + +def _is_provider_cost_candidate(record: CreditRecord) -> bool: + if record.type != CreditRecordType.CONSUME.value: + return False + if record.charge_action not in {None, "", CreditRecordAction.CHARGE.value}: + return False + charge_kind = str(record.charge_kind or "").strip() + if charge_kind in NON_PROVIDER_CHARGE_KINDS: + return False + if charge_kind in PROCESSABLE_CHARGE_KINDS: + return True + if str(record.media_type or "").lower() in {"image", "video"}: + return True + if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)): + return True + return bool(record.token_usage_id or _owner_key(record)) + + +def _raw_references(owner: Any) -> Any: + if owner is None: + return None + if isinstance(owner, ShotReplicateTaskSet): + return [ + { + "type": "video", + "url": owner.video_url, + "path": owner.video_path, + "duration_seconds": owner.video_duration_seconds, + "role": "reference_video", + "billable_input": True, + } + ] + if isinstance(owner, ShotReplicateSegment): + return [ + { + "type": "video", + "url": owner.segment_video_url, + "path": owner.segment_video_path, + "duration_seconds": owner.duration_seconds, + "role": "reference_video", + "billable_input": True, + } + ] + if hasattr(owner, "media_references"): + return getattr(owner, "media_references", None) + if hasattr(owner, "input_json"): + return getattr(owner, "input_json", None) + return None + + +def _provider_response(owner: Any) -> Any: + if owner is None: + return None + for name in ( + "provider_response_json", + "output_json", + "analysis_raw_json", + "analysis_result_json", + "analysis_json", + ): + value = getattr(owner, name, None) + if value: + return value + return None + + +def _nested_usage(value: Any) -> dict[str, Any]: + data = safe_json_dict(value) + if not data: + return {} + usage = data.get("usage") + if isinstance(usage, Mapping): + return deepcopy(dict(usage)) + for key in ("result", "payload", "data", "response"): + child = data.get(key) + if isinstance(child, Mapping): + found = _nested_usage(child) + if found: + return found + return {} + + +def _select_token_usage(record: CreditRecord, owner: Any, ctx: BackfillContext) -> TokenUsage | None: + token_usage_id = ( + record.token_usage_id + or getattr(owner, "token_usage_id", None) + ) + if token_usage_id and token_usage_id in ctx.token_usage_by_id: + return ctx.token_usage_by_id[token_usage_id] + key = _owner_key(record) + if key and key in ctx.token_usage_by_owner: + return ctx.token_usage_by_owner[key] + return None + + +def _usage_from_record(record: CreditRecord, owner: Any, token_usage: TokenUsage | None) -> dict[str, Any]: + usage = deepcopy(dict(record.usage_snapshot_json or {})) + payload_usage = _nested_usage(_provider_response(owner)) + for key, value in payload_usage.items(): + usage.setdefault(key, value) + + owner_input = safe_int(getattr(owner, "input_tokens", None)) + owner_output = safe_int(getattr(owner, "output_tokens", None)) + owner_total = safe_int(getattr(owner, "total_tokens", None)) + token_input = safe_int(getattr(token_usage, "input_tokens", None)) + token_output = safe_int(getattr(token_usage, "output_tokens", None)) + token_total = safe_int(getattr(token_usage, "total_tokens", None)) + + input_tokens = max(0, safe_int(record.input_tokens, token_input or owner_input)) + output_tokens = max(0, safe_int(record.output_tokens, token_output or owner_output)) + total_tokens = max( + 0, + safe_int(record.total_tokens, token_total or owner_total or (input_tokens + output_tokens)), + ) + if total_tokens <= 0: + total_tokens = input_tokens + output_tokens + if output_tokens <= 0 and total_tokens > input_tokens: + output_tokens = total_tokens - input_tokens + + usage.update( + { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": total_tokens, + "context_tokens": max(0, safe_int(usage.get("context_tokens"), input_tokens)), + "usage_source": "backfill", + "provider_usage_primary": bool(record.provider_usage_primary), + } + ) + return usage + + +def _get_engine_object(record: CreditRecord, owner: Any, ctx: BackfillContext) -> Any: + engine_id = str(record.engine_id or getattr(owner, "engine_id", None) or "").strip() + if not engine_id: + return None + media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower() + if media_type == "image": + return ctx.image_engines.get(engine_id) + if media_type == "video": + return ctx.video_engines.get(engine_id) + return ctx.image_engines.get(engine_id) or ctx.video_engines.get(engine_id) + + +def _restore_engine_snapshot( + record: CreditRecord, + *, + owner: Any, + linked_chat: ChatGenerationTask | None, + token_usage: TokenUsage | None, + ctx: BackfillContext, +) -> None: + response = safe_json_dict(_provider_response(linked_chat or owner)) + owner_snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None)) + chat_snapshot = safe_json_dict(getattr(linked_chat, "engine_snapshot_json", None)) + engine = _get_engine_object(record, linked_chat or owner, ctx) + + model_config_id = ( + getattr(owner, "model_config_id", None) + or getattr(token_usage, "model_config_id", None) + or (record.engine_id if record.engine_type == "model" else None) + or response.get("model_config_id") + ) + model_config = ctx.model_configs.get(str(model_config_id)) if model_config_id else None + + model_name = ( + record.engine_model_name + or response.get("model") + or response.get("model_name") + or chat_snapshot.get("model_name") + or chat_snapshot.get("engine_model_name") + or owner_snapshot.get("model_name") + or owner_snapshot.get("engine_model_name") + or getattr(engine, "model_name", None) + or getattr(model_config, "model_name", None) + ) + provider = ( + record.engine_provider + or chat_snapshot.get("provider") + or chat_snapshot.get("engine_provider") + or owner_snapshot.get("provider") + or owner_snapshot.get("engine_provider") + or getattr(engine, "provider", None) + or getattr(model_config, "provider", None) + ) + if not provider and str(model_name or "").lower().startswith("doubao-"): + provider = "volcengine" + + engine_id = ( + record.engine_id + or getattr(linked_chat, "engine_id", None) + or getattr(owner, "engine_id", None) + or chat_snapshot.get("engine_id") + or chat_snapshot.get("id") + or owner_snapshot.get("engine_id") + or owner_snapshot.get("id") + or getattr(engine, "id", None) + or getattr(model_config, "id", None) + ) + engine_name = ( + record.engine_name + or chat_snapshot.get("engine_name") + or chat_snapshot.get("name") + or owner_snapshot.get("engine_name") + or owner_snapshot.get("name") + or getattr(engine, "name", None) + or getattr(model_config, "name", None) + ) + + record.engine_id = str(engine_id) if engine_id else None + record.engine_name = str(engine_name) if engine_name else None + record.engine_model_name = str(model_name) if model_name else None + record.engine_provider = ( + normalize_provider(str(provider), record.engine_model_name) + if provider or record.engine_model_name + else None + ) + if not record.engine_type: + if model_config is not None: + record.engine_type = "model" + else: + record.engine_type = str(record.media_type or getattr(linked_chat or owner, "gen_type", None) or "") or None + + +def _infer_category(record: CreditRecord, owner: Any) -> str | None: + charge_kind = str(record.charge_kind or "").strip() + if charge_kind in {CreditRecordChargeKind.TEXT_PROMPT.value, CreditRecordChargeKind.VIDEO_ANALYSIS.value}: + return "text" + media_type = str(record.media_type or getattr(owner, "gen_type", None) or "").lower() + if media_type in {"image", "video"}: + return media_type + if any(int(value or 0) > 0 for value in (record.input_tokens, record.output_tokens, record.total_tokens)): + return "text" + return None + + +def _apply_unique_current_rule_fallback(record: CreditRecord, owner: Any, ctx: BackfillContext) -> None: + if record.engine_model_name: + return + category = _infer_category(record, owner) + rules = ctx.current_rules_by_category.get(category or "", []) + unique_models = {(rule.provider, rule.model_name) for rule in rules} + if len(unique_models) != 1: + return + provider, model_name = next(iter(unique_models)) + record.engine_provider = provider + record.engine_model_name = model_name + record.engine_type = record.engine_type or ("model" if category == "text" else category) + + +def _apply_snapshot_fields( + record: CreditRecord, + *, + attachment_snapshot: dict[str, Any] | None, + attachment_counts: dict[str, Any] | None, + generation_snapshot: dict[str, Any] | None, + generation_counts: dict[str, Any] | None, +) -> None: + if attachment_snapshot is not None: + record.attachment_snapshot_json = deepcopy(attachment_snapshot) + for key, value in (attachment_counts or {}).items(): + if hasattr(record, key): + setattr(record, key, value) + + if generation_snapshot is not None: + record.generation_snapshot_json = deepcopy(generation_snapshot) + for key, value in (generation_counts or {}).items(): + if hasattr(record, key): + setattr(record, key, value) + + +def _merge_resource_counts( + *, + record: CreditRecord, + generation_snapshot: dict[str, Any] | None, + generation_counts: dict[str, Any] | None, + resource_counts: dict[tuple[str, str], dict[str, int]], +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + source_model_by_owner_type = { + CreditRecordOwnerType.CHAT_GENERATION_TASK.value: SOURCE_MODEL_CHAT_TASK, + CreditRecordOwnerType.GENERATION_RECORD.value: SOURCE_MODEL_GENERATION_RECORD, + } + source_model = source_model_by_owner_type.get(record.owner_type or "") + bucket = ( + resource_counts.get((source_model, record.owner_id)) + if source_model and record.owner_id + else None + ) + if not bucket: + return generation_snapshot, generation_counts + + counts = deepcopy(dict(generation_counts or {})) + counts.update( + { + "generated_image_count": int(bucket["image"]), + "generated_video_count": int(bucket["video"]), + "generated_total_count": int(bucket["image"] + bucket["video"]), + } + ) + snapshot = deepcopy(dict(generation_snapshot or {})) + snapshot["generated_image_count"] = counts["generated_image_count"] + snapshot["generated_video_count"] = counts["generated_video_count"] + snapshot["generated_total_count"] = counts["generated_total_count"] + snapshot["output_count_source"] = "generated_resource" + return snapshot, counts + + +def _ensure_image_output_items( + *, + usage: dict[str, Any], + owner: Any, + successful_count: int, +) -> None: + if successful_count <= 0: + return + items = usage.get("output_items") + if isinstance(items, list) and len(items) >= successful_count: + return + width, height = parse_size(getattr(owner, "image_px", None)) + if width <= 0 or height <= 0: + return + usage["output_items"] = [ + { + "index": index, + "width": width, + "height": height, + "pixels": width * height, + "size_source": "request_explicit_backfill", + } + for index in range(successful_count) + ] + usage["output_pixels_are_estimated"] = True + + +async def _load_context( + db, + records: list[CreditRecord], + *, + backfill_reference_at: datetime, +) -> BackfillContext: + ctx = BackfillContext() + ids_by_type: dict[str, set[str]] = {} + for record in records: + key = _owner_key(record) + if key: + ids_by_type.setdefault(key[0], set()).add(key[1]) + if record.source_step_id: + ids_by_type.setdefault(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, set()).add(record.source_step_id) + + model_by_owner = { + CreditRecordOwnerType.CHAT_GENERATION_TASK.value: ChatGenerationTask, + CreditRecordOwnerType.GENERATION_RECORD.value: GenerationRecord, + CreditRecordOwnerType.MODULE_GENERATION_PROJECT.value: ModuleGenerationProject, + CreditRecordOwnerType.MODULE_GENERATION_STEP.value: ModuleGenerationStep, + CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value: ShotReplicateTaskSet, + CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value: ShotReplicateSegment, + } + for owner_type, model in model_by_owner.items(): + ids = ids_by_type.get(owner_type) or set() + if not ids: + ctx.owner_maps[owner_type] = {} + continue + rows = (await db.execute(select(model).where(model.id.in_(ids)))).scalars().all() + ctx.owner_maps[owner_type] = {row.id: row for row in rows} + + step_rows = list(ctx.owner_maps.get(CreditRecordOwnerType.MODULE_GENERATION_STEP.value, {}).values()) + linked_chat_ids = {str(step.chat_task_id) for step in step_rows if getattr(step, "chat_task_id", None)} + if linked_chat_ids: + rows = ( + await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(linked_chat_ids))) + ).scalars().all() + ctx.linked_chat_tasks = {row.id: row for row in rows} + + token_usage_ids = {str(record.token_usage_id) for record in records if record.token_usage_id} + token_usage_ids.update( + str(step.token_usage_id) + for step in step_rows + if getattr(step, "token_usage_id", None) + ) + owner_ids = {key[1] for record in records if (key := _owner_key(record))} + token_filters = [] + if token_usage_ids: + token_filters.append(TokenUsage.id.in_(token_usage_ids)) + if owner_ids: + token_filters.append(TokenUsage.owner_id.in_(owner_ids)) + if token_filters: + token_rows = ( + await db.execute( + select(TokenUsage) + .where(or_(*token_filters)) + .order_by(TokenUsage.created_at.desc()) + ) + ).scalars().all() + for row in token_rows: + ctx.token_usage_by_id[row.id] = row + if row.owner_type and row.owner_id: + ctx.token_usage_by_owner.setdefault((row.owner_type, row.owner_id), row) + + model_config_ids = { + str(row.model_config_id) + for row in ctx.token_usage_by_id.values() + if row.model_config_id + } + model_config_ids.update( + str(step.model_config_id) + for step in step_rows + if getattr(step, "model_config_id", None) + ) + model_config_ids.update( + str(record.engine_id) + for record in records + if record.engine_type == "model" and record.engine_id + ) + if model_config_ids: + rows = ( + await db.execute(select(ModelConfig).where(ModelConfig.id.in_(model_config_ids))) + ).scalars().all() + ctx.model_configs = {row.id: row for row in rows} + + engine_ids = {str(record.engine_id) for record in records if record.engine_id} + for owner_map in ctx.owner_maps.values(): + engine_ids.update(str(row.engine_id) for row in owner_map.values() if getattr(row, "engine_id", None)) + engine_ids.update(str(row.engine_id) for row in ctx.linked_chat_tasks.values() if row.engine_id) + if engine_ids: + image_rows = ( + await db.execute(select(ImageEngine).where(ImageEngine.id.in_(engine_ids))) + ).scalars().all() + video_rows = ( + await db.execute(select(VideoEngine).where(VideoEngine.id.in_(engine_ids))) + ).scalars().all() + ctx.image_engines = {row.id: row for row in image_rows} + ctx.video_engines = {row.id: row for row in video_rows} + + source_ids = { + record.owner_id + for record in records + if record.owner_id + and record.owner_type + in { + CreditRecordOwnerType.CHAT_GENERATION_TASK.value, + CreditRecordOwnerType.GENERATION_RECORD.value, + } + } + if source_ids: + resources = ( + await db.execute( + select(GeneratedResource) + .where(GeneratedResource.source_id.in_(source_ids)) + .where(GeneratedResource.deleted_at.is_(None)) + ) + ).scalars().all() + for resource in resources: + key = (str(resource.source_model or ""), resource.source_id) + bucket = ctx.resource_counts.setdefault(key, {"image": 0, "video": 0}) + resource_type = str(resource.resource_type or "").lower() + if resource_type in bucket: + bucket[resource_type] += 1 + + current_rules = ( + await db.execute( + select(ModelPricingRule) + .where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value) + .where(ModelPricingRule.effective_from <= backfill_reference_at) + .where( + or_( + ModelPricingRule.effective_to.is_(None), + ModelPricingRule.effective_to > backfill_reference_at, + ) + ) + .order_by(ModelPricingRule.model_category, ModelPricingRule.model_name) + ) + ).scalars().all() + for rule in current_rules: + ctx.current_rules_by_category.setdefault(rule.model_category, []).append(rule) + + return ctx + + +def _record_state(record: CreditRecord) -> tuple[Any, ...]: + return ( + record.engine_id, + record.engine_provider, + record.engine_model_name, + record.pricing_rule_id, + record.pricing_version_code, + record.provider_cost_status, + record.provider_cost_amount, + record.pricing_snapshot_hash, + record.attachment_total_count, + record.generated_total_count, + deepcopy(record.usage_snapshot_json), + deepcopy(record.attachment_snapshot_json), + deepcopy(record.generation_snapshot_json), + ) + + +def _classify_result(record: CreditRecord, stats: BackfillStats, *, previous_rule_id: str | None, forced: bool) -> None: + status = record.provider_cost_status + if record.pricing_rule_id and record.pricing_rule_id != previous_rule_id: + stats.rule_bound += 1 + if status == ProviderCostStatus.CALCULATED.value: + stats.calculated += 1 + if forced: + stats.force_repriced += 1 + elif status == ProviderCostStatus.ESTIMATED.value: + stats.estimated += 1 + if forced: + stats.force_repriced += 1 + elif status == ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value: + stats.missing_engine += 1 + elif status == ProviderCostStatus.USAGE_MISSING.value: + stats.missing_usage += 1 + elif status == ProviderCostStatus.UNMATCHED_RULE.value: + stats.unmatched_rule += 1 + elif status == ProviderCostStatus.NOT_APPLICABLE.value: + stats.not_applicable += 1 + + +async def run(args) -> None: + start_at = _parse_date(args.start_date) + end_at = _parse_date(args.end_date, end=True) + backfill_reference_at = _utcnow() + total = BackfillStats() + last_id: str | None = None + + async with async_session() as db: + while True: + filters = [CreditRecord.type == CreditRecordType.CONSUME.value] + filters.append( + or_( + CreditRecord.charge_action == CreditRecordAction.CHARGE.value, + CreditRecord.charge_action.is_(None), + CreditRecord.charge_action == "", + ) + ) + filters.append(CreditRecord.created_at <= backfill_reference_at) + if last_id: + filters.append(CreditRecord.id > last_id) + if start_at: + filters.append(CreditRecord.created_at >= start_at) + if end_at: + filters.append(CreditRecord.created_at <= end_at) + if args.only_user_id: + filters.append(CreditRecord.user_id == args.only_user_id) + if args.only_owner_type: + filters.append(CreditRecord.owner_type == args.only_owner_type) + if args.only_missing and not args.force: + filters.append( + or_( + CreditRecord.pricing_rule_id.is_(None), + CreditRecord.provider_cost_status.is_(None), + CreditRecord.provider_cost_status.in_([value for value in INCOMPLETE_COST_STATUSES if value]), + CreditRecord.attachment_snapshot_json.is_(None), + CreditRecord.generation_snapshot_json.is_(None), + ) + ) + + records = ( + await db.execute( + select(CreditRecord) + .where(*filters) + .order_by(CreditRecord.id) + .limit(args.batch_size) + .with_for_update(skip_locked=True) + ) + ).scalars().all() + if not records: + break + + ctx = await _load_context( + db, + records, + backfill_reference_at=backfill_reference_at, + ) + batch = BackfillStats() + + for record in records: + batch.scanned += 1 + record_id = str(record.id) + user_id = str(record.user_id) if record.user_id else None + owner_type = str(record.owner_type or "") or None + owner_id = str(record.owner_id or record.related_id or "") or None + + if not _is_provider_cost_candidate(record): + batch.skipped_non_provider += 1 + continue + if ( + not args.force + and record.provider_cost_status == ProviderCostStatus.CALCULATED.value + and record.pricing_rule_id + and record.attachment_snapshot_json is not None + and record.generation_snapshot_json is not None + ): + batch.skipped_completed += 1 + continue + + before = _record_state(record) + previous_rule_id = record.pricing_rule_id + try: + async with db.begin_nested(): + key = _owner_key(record) + owner = ctx.owner_maps.get(key[0], {}).get(key[1]) if key else None + if owner is None and record.source_step_id: + owner = ctx.owner_maps.get( + CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + {}, + ).get(record.source_step_id) + + linked_chat = None + if isinstance(owner, ModuleGenerationStep) and owner.chat_task_id: + linked_chat = ctx.linked_chat_tasks.get(owner.chat_task_id) + media_owner = linked_chat or owner + token_usage = _select_token_usage(record, owner, ctx) + + usage = _usage_from_record(record, owner, token_usage) + attachment_snapshot, attachment_counts = build_attachment_snapshot( + _raw_references(media_owner or owner) + ) + generation_snapshot: dict[str, Any] | None = deepcopy(record.generation_snapshot_json) + generation_counts: dict[str, Any] | None = None + + gen_type = str( + record.media_type + or getattr(media_owner, "gen_type", None) + or "" + ).lower() + response = _provider_response(media_owner or owner) + if gen_type in {"image", "video"} and media_owner is not None: + generation_snapshot, generation_counts, generation_usage = build_generation_snapshot( + media_owner, + provider_response=response, + stage=PricingSnapshotStage.BACKFILL.value, + ) + provider_usage = normalize_provider_media_usage( + response, + gen_type=gen_type, + fallback_total_tokens=max( + safe_int(record.total_tokens), + safe_int(getattr(media_owner, "video_tokens_used", None)), + ), + request_image_px=getattr(media_owner, "image_px", None), + requested_output_count=max( + 1, + safe_int((generation_counts or {}).get("requested_output_count"), 1), + ), + provider_input_image_count=safe_int( + attachment_counts.get("provider_input_image_count") + ), + ) + usage.update(generation_usage) + usage.update(provider_usage) + usage.update( + { + "has_input_video": bool( + attachment_counts.get("provider_input_video_count") + ), + "provider_input_image_count": safe_int( + provider_usage.get("provider_input_image_count"), + safe_int(attachment_counts.get("provider_input_image_count")), + ), + "input_image_count": safe_int( + provider_usage.get("provider_input_image_count"), + safe_int(attachment_counts.get("provider_input_image_count")), + ), + "input_video_duration_seconds": float( + attachment_counts.get("attachment_video_duration_seconds") or 0 + ), + "input_audio_duration_seconds": float( + attachment_counts.get("attachment_audio_duration_seconds") or 0 + ), + "usage_stage": PricingSnapshotStage.BACKFILL.value, + } + ) + + generation_snapshot, generation_counts = _merge_resource_counts( + record=record, + generation_snapshot=generation_snapshot, + generation_counts=generation_counts, + resource_counts=ctx.resource_counts, + ) + if generation_counts: + successful_count = ( + int(generation_counts.get("generated_image_count") or 0) + if gen_type == "image" + else int(generation_counts.get("generated_video_count") or 0) + ) + usage["successful_output_count"] = successful_count + usage["generated_image_count"] = int( + generation_counts.get("generated_image_count") or 0 + ) + usage["generated_video_count"] = int( + generation_counts.get("generated_video_count") or 0 + ) + if gen_type == "image": + _ensure_image_output_items( + usage=usage, + owner=media_owner, + successful_count=successful_count, + ) + + _restore_engine_snapshot( + record, + owner=owner, + linked_chat=linked_chat, + token_usage=token_usage, + ctx=ctx, + ) + _apply_unique_current_rule_fallback(record, media_owner or owner, ctx) + + if record.charge_action in {None, ""}: + record.charge_action = CreditRecordAction.CHARGE.value + + _apply_snapshot_fields( + record, + attachment_snapshot=attachment_snapshot, + attachment_counts=attachment_counts, + generation_snapshot=generation_snapshot, + generation_counts=generation_counts, + ) + + await finalize_credit_record_pricing( + db, + charge=record, + usage=usage, + stage=PricingSnapshotStage.BACKFILL.value, + attachment_snapshot=attachment_snapshot, + attachment_counts=attachment_counts, + generation_snapshot=generation_snapshot, + generation_counts=generation_counts, + allow_upgrade_estimated=True, + pricing_reference_at=backfill_reference_at, + use_locked_rule=False, + force_reprice=bool(args.force), + backfill_metadata={ + "is_backfilled": True, + "pricing_basis": "current_published_rule", + "backfill_reference_at": backfill_reference_at.isoformat(), + "original_credit_created_at": ( + record.created_at.isoformat() if record.created_at else None + ), + "command": "backfill_credit_record_snapshots", + }, + ) + + after = _record_state(record) + if before != after: + batch.changed += 1 + _classify_result( + record, + batch, + previous_rule_id=previous_rule_id, + forced=bool(args.force), + ) + except Exception as exc: + batch.failed += 1 + log_model_pricing_event( + event_type="pricing_backfill_record_failed", + event_status="failed", + user_id=user_id, + credit_record_id=record_id, + owner_type=owner_type, + owner_id=owner_id, + error=str(exc), + detail={ + "backfill_reference_at": backfill_reference_at.isoformat(), + "force": bool(args.force), + }, + ) + + last_id = str(records[-1].id) + total.merge(batch) + log_model_pricing_event( + event_type="pricing_backfill_batch", + event_status="success" if batch.failed == 0 else "warning", + detail={ + **batch.as_dict(), + "batch_size": len(records), + "last_id": last_id, + "commit": bool(args.commit), + "force": bool(args.force), + "backfill_reference_at": backfill_reference_at.isoformat(), + }, + ) + if args.commit: + await db.commit() + else: + await db.rollback() + + print( + f"batch={len(records)} scanned={batch.scanned} changed={batch.changed} " + f"bound={batch.rule_bound} calculated={batch.calculated} estimated={batch.estimated} " + f"missing_engine={batch.missing_engine} missing_usage={batch.missing_usage} " + f"unmatched_rule={batch.unmatched_rule} skipped_completed={batch.skipped_completed} " + f"skipped_non_provider={batch.skipped_non_provider} failed={batch.failed} last_id={last_id}" + ) + + mode = "COMMIT" if args.commit else "DRY-RUN" + summary = " ".join(f"{key}={value}" for key, value in total.as_dict().items()) + print( + f"{mode} DONE backfill_reference_at={backfill_reference_at.isoformat()} " + f"force={bool(args.force)} {summary}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "一次性将历史消费流水按执行时当前已发布的模型计价规则补齐:" + "同时恢复模型、附件/产出快照、绑定规则并计算供应商成本。" + ) + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dry-run", action="store_true", help="执行完整计算但最终回滚") + group.add_argument("--commit", action="store_true", help="分批提交补录结果") + parser.add_argument("--batch-size", type=int, default=500) + parser.add_argument("--start-date") + parser.add_argument("--end-date") + parser.add_argument("--only-user-id") + parser.add_argument("--only-owner-type") + parser.add_argument( + "--only-missing", + action="store_true", + default=False, + help="仅扫描规则/成本或附件/产出快照尚未完整的流水", + ) + parser.add_argument( + "--force", + action="store_true", + default=False, + help="按当前发布规则覆盖已经核算过的历史计价结果", + ) + args = parser.parse_args() + args.batch_size = max(1, min(args.batch_size, 5000)) + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/video-gen-api/app/cmd/seed_model_pricing_rules.py b/video-gen-api/app/cmd/seed_model_pricing_rules.py new file mode 100644 index 00000000..546c90c7 --- /dev/null +++ b/video-gen-api/app/cmd/seed_model_pricing_rules.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import argparse +import asyncio + +from sqlalchemy import select + +from app.models.base import async_session +from app.models.menu_config import MenuConfig +from app.models.model_pricing_rule import ModelPricingRule +from app.services.model_pricing.rule_service import create_rule +from app.services.model_pricing.seed_data import volcengine_pricing_seed_rules +from app.utils.id_gen import generate_id + + +async def _ensure_admin_menu(db) -> bool: + exists = ( + await db.execute( + select(MenuConfig.id) + .where(MenuConfig.menu_target == "admin") + .where(MenuConfig.path == "/model-pricing") + .limit(1) + ) + ).scalar_one_or_none() + if exists: + return False + + group_id = ( + await db.execute( + select(MenuConfig.id) + .where(MenuConfig.menu_target == "admin") + .where(MenuConfig.menu_type == "group") + .where(MenuConfig.label == "模型设置") + .limit(1) + ) + ).scalar_one_or_none() + if not group_id: + group_id = generate_id() + db.add( + MenuConfig( + id=group_id, + path="", + label="模型设置", + icon="RobotOutlined", + sort_order=98, + is_active=True, + menu_type="group", + menu_target="admin", + ) + ) + await db.flush() + + db.add( + MenuConfig( + id=generate_id(), + path="/model-pricing", + label="模型计价", + icon="DollarOutlined", + sort_order=4, + is_active=True, + menu_type="page", + menu_target="admin", + parent_id=group_id, + ) + ) + await db.flush() + return True + + +async def run(*, commit: bool) -> None: + async with async_session() as db: + created = skipped = 0 + menu_created = await _ensure_admin_menu(db) + for payload in volcengine_pricing_seed_rules(): + exists = ( + await db.execute( + select(ModelPricingRule.id) + .where(ModelPricingRule.provider == payload["provider"]) + .where(ModelPricingRule.model_name == payload["model_name"]) + .where(ModelPricingRule.version_code == payload["version_code"]) + .limit(1) + ) + ).scalar_one_or_none() + if exists: + skipped += 1 + print(f"SKIP {payload['model_name']} {payload['version_code']} id={exists}") + continue + draft_payload = dict(payload) + draft_payload.pop("publish_status", None) + snapshot = await create_rule(db, payload=draft_payload, operator_id=None) + created += 1 + effective_from = snapshot["effective_from"] + print( + f"CREATE_DRAFT {snapshot['model_name']} {snapshot['version_code']} id={snapshot['id']} " + f"effective_from={effective_from.isoformat()}" + ) + if commit: + await db.commit() + print(f"COMMIT created={created} skipped={skipped} menu_created={menu_created}") + else: + await db.rollback() + print(f"DRY-RUN created={created} skipped={skipped} menu_created={menu_created}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="初始化火山模型计价草稿(不会自动发布,需人工核价后在后台发布)") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dry-run", action="store_true") + group.add_argument("--commit", action="store_true") + args = parser.parse_args() + asyncio.run(run(commit=bool(args.commit))) + + +if __name__ == "__main__": + main() diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index cf044acf..7f4621f8 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -112,6 +112,8 @@ class Settings(BaseSettings): # ChatAPI async generation pipeline settings CELERY_BROKER_URL: str = "" CELERY_RESULT_BACKEND: str = "" + # Celery result backend 仅保留近期排障状态;业务恢复以数据库状态和业务日志为准。 + CELERY_RESULT_EXPIRES_SECONDS: int = 7200 # Celery async 兼容配置。 # single_loop:每个 Celery 子进程一个专用 event loop,推荐线上/本地统一使用。 # direct:旧版线程本地 loop 降级模式,建议配合 CELERY_DB_USE_NULLPOOL=true。 diff --git a/video-gen-api/app/enums/model_pricing.py b/video-gen-api/app/enums/model_pricing.py new file mode 100644 index 00000000..409eac6f --- /dev/null +++ b/video-gen-api/app/enums/model_pricing.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from enum import Enum + + +class ModelPricingProvider(str, Enum): + VOLCENGINE = "volcengine" + + +class ModelPricingCategory(str, Enum): + TEXT = "text" + IMAGE = "image" + VIDEO = "video" + + +class ModelPricingBillingMode(str, Enum): + TEXT_TOKEN_TIERED = "text_token_tiered" + IMAGE_PER_OUTPUT = "image_per_output" + IMAGE_INPUT_OUTPUT_TIERED = "image_input_output_tiered" + VIDEO_TOKEN_RATE = "video_token_rate" + + +class ModelPricingCalculatorVersion(str, Enum): + TEXT_TOKEN_TIERED_V1 = "text_token_tiered_v1" + IMAGE_PER_OUTPUT_V1 = "image_per_output_v1" + IMAGE_INPUT_OUTPUT_TIERED_V1 = "image_input_output_tiered_v1" + VIDEO_PIXEL_TOKEN_V1 = "video_pixel_token_v1" + + +class ModelPricingRuleStatus(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + DISABLED = "disabled" + + +class ProviderCostStatus(str, Enum): + NOT_APPLICABLE = "not_applicable" + NOT_INCURRED = "not_incurred" + PENDING = "pending" + CALCULATED = "calculated" + ESTIMATED = "estimated" + UNMATCHED_RULE = "unmatched_rule" + USAGE_MISSING = "usage_missing" + ERROR = "error" + PROVIDER_RESULT_UNCERTAIN = "provider_result_uncertain" + HISTORICAL_PRICE_UNAVAILABLE = "historical_price_unavailable" + HISTORICAL_ENGINE_UNAVAILABLE = "historical_engine_unavailable" + + +class PricingUsageSource(str, Enum): + PROVIDER = "provider" + PROVIDER_RESPONSE = "provider_response" + REQUEST_FORMULA = "request_formula" + ENGINE_SNAPSHOT = "engine_snapshot" + PRICING_RULE_MAP = "pricing_rule_map" + BACKFILL = "backfill" + MANUAL = "manual" + UNAVAILABLE = "unavailable" + + +class PricingSnapshotStage(str, Enum): + REQUEST_LOCKED = "request_locked" + PROVIDER_SYNC_COMPLETED = "provider_sync_completed" + PROVIDER_ASYNC_COMPLETED = "provider_async_completed" + RESOURCE_DOWNLOAD_COMPLETED = "resource_download_completed" + BACKFILL = "backfill" + + +class PricingDimensionSource(str, Enum): + PROVIDER_RESPONSE = "provider_response" + REQUEST_EXPLICIT = "request_explicit" + ENGINE_SNAPSHOT = "engine_snapshot" + PRICING_RULE_MAP = "pricing_rule_map" + UNAVAILABLE = "unavailable" + + +class PricingBillBy(str, Enum): + SUCCESSFUL_OUTPUT_COUNT = "successful_output_count" + REQUESTED_OUTPUT_COUNT = "requested_output_count" + PROVIDER_BILLED_COUNT = "provider_billed_count" + + +class PricingInferenceMode(str, Enum): + ONLINE = "online" + FLEX = "flex" + BATCH = "batch" + + +MODEL_PRICING_BILLING_MODE_LABELS = { + ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: "文本分档 Token 计价", + ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: "图片按输出数量计价", + ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: "输入图片 + 输出像素分档计价", + ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: "视频像素 Token 计价", +} + +MODEL_PRICING_RULE_STATUS_LABELS = { + ModelPricingRuleStatus.DRAFT.value: "草稿", + ModelPricingRuleStatus.PUBLISHED.value: "已发布", + ModelPricingRuleStatus.DISABLED.value: "已停用", +} + +PROVIDER_COST_STATUS_LABELS = { + ProviderCostStatus.NOT_APPLICABLE.value: "不涉及供应商成本", + ProviderCostStatus.NOT_INCURRED.value: "供应商费用未发生", + ProviderCostStatus.PENDING.value: "待核算", + ProviderCostStatus.CALCULATED.value: "已核算", + ProviderCostStatus.ESTIMATED.value: "估算", + ProviderCostStatus.UNMATCHED_RULE.value: "未匹配价格", + ProviderCostStatus.USAGE_MISSING.value: "用量缺失", + ProviderCostStatus.ERROR.value: "核算异常", + ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value: "供应商结果不确定", + ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value: "历史价格缺失", + ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value: "历史引擎缺失", +} diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py index 2d5744ba..b6efceb0 100644 --- a/video-gen-api/app/models/__init__.py +++ b/video-gen-api/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.project import Project from app.models.generation_record import GenerationRecord from app.models.credit_record import CreditRecord from app.models.model_config import ModelConfig +from app.models.model_pricing_rule import ModelPricingRule from app.models.system_config import SystemConfig from app.models.notification import Notification from app.models.notification_read import NotificationRead @@ -41,7 +42,7 @@ __all__ = [ "Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session", "init_database", "close_database", "User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord", - "ModelConfig", "SystemConfig", "Notification", "PaymentOrder", + "ModelConfig", "ModelPricingRule", "SystemConfig", "Notification", "PaymentOrder", "TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio", "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", diff --git a/video-gen-api/app/models/chat_generation_task.py b/video-gen-api/app/models/chat_generation_task.py index 4bb3e1c3..6ad7ebd6 100644 --- a/video-gen-api/app/models/chat_generation_task.py +++ b/video-gen-api/app/models/chat_generation_task.py @@ -73,6 +73,8 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin): engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True) provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True) + # 当前媒体扣费尝试号;Provider 回调必须按 owner + attempt_no 精确回填。 + current_billing_attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) credits_cost: Mapped[float] = mapped_column(Float, default=0.0) text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0) diff --git a/video-gen-api/app/models/credit_record.py b/video-gen-api/app/models/credit_record.py index 45d4322b..cb7ce1c6 100644 --- a/video-gen-api/app/models/credit_record.py +++ b/video-gen-api/app/models/credit_record.py @@ -1,14 +1,22 @@ -from sqlalchemy import Float, ForeignKey, Index, Integer, String +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, JSON, Numeric, String +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin +JsonType = JSON().with_variant(JSONB, "postgresql") + + class CreditRecord(Base, TimestampMixin): __tablename__ = "credit_records" __table_args__ = ( - # 正式计费幂等键:同一用户同一个业务流水只能写入一次。 - # PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。 Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True), Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"), Index("ix_credit_records_related_type", "related_id", "type"), @@ -18,12 +26,13 @@ class CreditRecord(Base, TimestampMixin): Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"), Index("ix_credit_records_team_time", "team_id_snapshot", "created_at"), Index("ix_credit_records_user_kind_team_time", "user_type_snapshot", "frontend_user_kind_snapshot", "team_id_snapshot", "created_at"), + Index("ix_credit_records_pricing_status_time", "provider_cost_status", "created_at"), + Index("ix_credit_records_pricing_model_time", "engine_provider", "engine_model_name", "pricing_reference_at"), + Index("ix_credit_records_pricing_version", "pricing_version_code", "pricing_rule_id"), ) id: Mapped[str] = mapped_column(String(32), primary_key=True) - user_id: Mapped[str] = mapped_column( - String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True - ) + user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True) type: Mapped[str] = mapped_column(String(16), index=True) amount: Mapped[float] = mapped_column(Float) balance_after: Mapped[float] = mapped_column(Float) @@ -59,15 +68,53 @@ class CreditRecord(Base, TimestampMixin): output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) - engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True) engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) engine_name: Mapped[str | None] = mapped_column(String(128), nullable=True) engine_provider: Mapped[str | None] = mapped_column(String(64), nullable=True) engine_model_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 不可变计价版本快照。后续调价不得基于规则表重新计算历史流水。 + pricing_rule_id: Mapped[str | None] = mapped_column( + String(32), ForeignKey("model_pricing_rules.id", ondelete="RESTRICT"), nullable=True, index=True + ) + pricing_version_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + pricing_billing_mode: Mapped[str | None] = mapped_column(String(48), nullable=True) + pricing_calculator_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + pricing_usage_source: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + pricing_reference_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + pricing_effective_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + pricing_effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + pricing_snapshot_schema_version: Mapped[int | None] = mapped_column(Integer, nullable=True) + pricing_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + + provider_cost_currency: Mapped[str | None] = mapped_column(String(8), nullable=True) + provider_cost_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 8), nullable=True) + provider_cost_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + provider_cost_calculated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + provider_cost_finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + provider_usage_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + provider_cost_is_estimated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + + # 财务高频聚合字段平铺,避免列表/导出时逐条解析 JSON 或回查任务链。 + attachment_image_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + attachment_video_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + attachment_audio_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + attachment_total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + attachment_video_duration_seconds: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False, default=0, server_default="0") + attachment_audio_duration_seconds: Mapped[Decimal] = mapped_column(Numeric(20, 6), nullable=False, default=0, server_default="0") + + requested_output_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + generated_image_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + generated_video_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + generated_total_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + + pricing_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True) + usage_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True) + attachment_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True) + generation_snapshot_json: Mapped[dict[str, Any] | None] = mapped_column(JsonType, nullable=True) + user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) - # 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。 team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/video-gen-api/app/models/generation_record.py b/video-gen-api/app/models/generation_record.py index 0aa585eb..c0a2eb64 100644 --- a/video-gen-api/app/models/generation_record.py +++ b/video-gen-api/app/models/generation_record.py @@ -35,6 +35,11 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin): DateTime(timezone=True), nullable=True ) seedance_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + # 生成开始时锁定实际引擎,后续提交、轮询和计价均使用同一快照。 + engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + engine_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True) + provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True) + current_billing_attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) credits_cost: Mapped[float] = mapped_column(Float, default=0.0) text_credits_cost: Mapped[float] = mapped_column(Float, default=0.0) text_tokens_used: Mapped[int] = mapped_column(Integer, default=0) diff --git a/video-gen-api/app/models/model_pricing_rule.py b/video-gen-api/app/models/model_pricing_rule.py new file mode 100644 index 00000000..32022a01 --- /dev/null +++ b/video-gen-api/app/models/model_pricing_rule.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import CheckConstraint, DateTime, Index, Integer, JSON, String, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base, TimestampMixin + + +JsonType = JSON().with_variant(JSONB, "postgresql") + + +class ModelPricingRule(Base, TimestampMixin): + __tablename__ = "model_pricing_rules" + __table_args__ = ( + Index( + "uq_model_pricing_rules_provider_model_version", + "provider", + "model_name", + "version_code", + unique=True, + ), + Index( + "ix_model_pricing_rules_resolve", + "provider", + "model_name", + "publish_status", + "effective_from", + "effective_to", + ), + Index("ix_model_pricing_rules_category_status", "model_category", "publish_status"), + CheckConstraint( + "effective_to IS NULL OR effective_to >= effective_from", + name="ck_model_pricing_rules_effective_range", + ), + ) + + id: Mapped[str] = mapped_column(String(32), primary_key=True) + provider: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + model_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + model_category: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + billing_mode: Mapped[str] = mapped_column(String(48), nullable=False, index=True) + calculator_version: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + version_code: Mapped[str] = mapped_column(String(64), nullable=False) + + effective_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + effective_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + publish_status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True) + + currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY") + rule_schema_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + # 规则 JSON 统一使用“构建新 dict 后整体赋值”,禁止嵌套原地修改。 + rule_json: Mapped[dict[str, Any]] = mapped_column(JsonType, nullable=False, default=dict) + rule_content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + + source_url: Mapped[str | None] = mapped_column(Text, nullable=True) + source_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + remark: Mapped[str | None] = mapped_column(Text, nullable=True) + created_by: Mapped[str | None] = mapped_column(String(32), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True) diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py index 429864b9..728da38b 100644 --- a/video-gen-api/app/schemas/admin.py +++ b/video-gen-api/app/schemas/admin.py @@ -135,6 +135,20 @@ class AdminCreditRecordSummaryOut(BaseModel): total_tokens: int = 0 input_tokens: int = 0 output_tokens: int = 0 + attachment_image_count: int = 0 + attachment_video_count: int = 0 + attachment_audio_count: int = 0 + attachment_total_count: int = 0 + generated_image_count: int = 0 + generated_video_count: int = 0 + generated_total_count: int = 0 + provider_cost_calculated_total: str = "0.00000000" + provider_cost_estimated_total: str = "0.00000000" + provider_cost_combined_total: str = "0.00000000" + provider_cost_total: str = "0.00000000" + provider_cost_pending_count: int = 0 + provider_cost_estimated_count: int = 0 + provider_cost_abnormal_count: int = 0 class AdminCreditRecordOut(BaseModel): @@ -187,6 +201,38 @@ class AdminCreditRecordOut(BaseModel): engine_name: str | None = None engine_provider: str | None = None engine_model_name: str | None = None + pricing_rule_id: str | None = None + pricing_version_code: str | None = None + pricing_billing_mode: str | None = None + pricing_billing_mode_label: str | None = None + pricing_calculator_version: str | None = None + pricing_usage_source: str | None = None + pricing_reference_at: str | None = None + pricing_effective_from: str | None = None + pricing_effective_to: str | None = None + pricing_snapshot_hash: str | None = None + provider_cost_currency: str = "CNY" + provider_cost_amount: str = "0.00000000" + provider_cost_status: str | None = None + provider_cost_status_label: str | None = None + provider_cost_calculated_at: str | None = None + provider_cost_finalized_at: str | None = None + provider_cost_is_estimated: bool = False + provider_usage_primary: bool = False + attachment_image_count: int = 0 + attachment_video_count: int = 0 + attachment_audio_count: int = 0 + attachment_total_count: int = 0 + attachment_video_duration_seconds: str = "0.000000" + attachment_audio_duration_seconds: str = "0.000000" + requested_output_count: int = 0 + generated_image_count: int = 0 + generated_video_count: int = 0 + generated_total_count: int = 0 + pricing_snapshot_json: dict | None = None + usage_snapshot_json: dict | None = None + attachment_snapshot_json: dict | None = None + generation_snapshot_json: dict | None = None created_at: str | None = None diff --git a/video-gen-api/app/schemas/model_pricing.py b/video-gen-api/app/schemas/model_pricing.py new file mode 100644 index 00000000..fc8cf58c --- /dev/null +++ b/video-gen-api/app/schemas/model_pricing.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field, model_validator + + +CALCULATOR_PATTERN = "^(text_token_tiered_v1|image_per_output_v1|image_input_output_tiered_v1|video_pixel_token_v1)$" + + +class ModelPricingRuleBase(BaseModel): + provider: str = Field(default="volcengine", max_length=32) + model_name: str = Field(..., min_length=1, max_length=128) + model_category: str = Field(..., pattern="^(text|image|video)$") + billing_mode: str = Field(..., pattern="^(text_token_tiered|image_per_output|image_input_output_tiered|video_token_rate)$") + calculator_version: str = Field(..., pattern=CALCULATOR_PATTERN) + version_code: str = Field(..., min_length=1, max_length=64) + effective_from: datetime + effective_to: datetime | None = None + currency: str = Field(default="CNY", min_length=3, max_length=8) + rule_schema_version: int = Field(default=1, ge=1, le=100) + rule_json: dict[str, Any] + source_url: str | None = None + source_updated_at: datetime | None = None + remark: str | None = None + + @model_validator(mode="after") + def validate_time_range(self): + if self.effective_to and self.effective_to <= self.effective_from: + raise ValueError("effective_to 必须晚于 effective_from") + return self + + +class ModelPricingRuleCreate(ModelPricingRuleBase): + pass + + +class ModelPricingRuleUpdate(BaseModel): + provider: str | None = Field(None, max_length=32) + model_name: str | None = Field(None, min_length=1, max_length=128) + model_category: str | None = Field(None, pattern="^(text|image|video)$") + billing_mode: str | None = Field(None, pattern="^(text_token_tiered|image_per_output|image_input_output_tiered|video_token_rate)$") + calculator_version: str | None = Field(None, pattern=CALCULATOR_PATTERN) + version_code: str | None = Field(None, min_length=1, max_length=64) + effective_from: datetime | None = None + effective_to: datetime | None = None + currency: str | None = Field(None, min_length=3, max_length=8) + rule_schema_version: int | None = Field(None, ge=1, le=100) + rule_json: dict[str, Any] | None = None + source_url: str | None = None + source_updated_at: datetime | None = None + remark: str | None = None + + +class ModelPricingRuleOut(ModelPricingRuleBase): + id: str + publish_status: str + rule_content_hash: str + referenced_count: int = 0 + created_by: str | None = None + updated_by: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ModelPricingRuleListOut(BaseModel): + items: list[ModelPricingRuleOut] + total: int + + +class ModelPricingPreviewRequest(BaseModel): + billing_mode: str + calculator_version: str = Field(..., pattern=CALCULATOR_PATTERN) + rule_json: dict[str, Any] + usage: dict[str, Any] + currency: str = "CNY" + + +class ModelPricingPreviewOut(BaseModel): + amount: str + currency: str + is_estimated: bool + selected_rate: str | None = None + usage_source: str + breakdown: dict[str, Any] 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..f859f6cd 100644 --- a/video-gen-api/app/services/admin_credit_record_service.py +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime, timezone, timedelta +from decimal import Decimal from typing import Any from sqlalchemy import and_, case, distinct, func, or_, select @@ -16,6 +17,10 @@ from app.enums.credit_record import ( CREDIT_RECORD_TYPE_LABELS, CreditRecordSubject, ) +from app.enums.model_pricing import ( + MODEL_PRICING_BILLING_MODE_LABELS, + PROVIDER_COST_STATUS_LABELS, +) from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType from app.enums.team import TEAM_UNASSIGNED_VALUE from app.models.chat_generation_task import ChatGenerationTask @@ -34,19 +39,16 @@ CST = timezone(timedelta(hours=8)) def _iso(dt: Any) -> str | None: if dt is None: return None - if isinstance(dt, datetime): if dt.tzinfo is None: - # 数据库已经按东八区业务时间返回但丢了 tzinfo 时,不再额外 +8 return dt.replace(tzinfo=CST).isoformat() - return dt.astimezone(CST).isoformat() - try: return dt.isoformat() except Exception: return str(dt) + def _round2(value: Any) -> float: try: return round(float(value or 0), 2) @@ -54,11 +56,19 @@ def _round2(value: Any) -> float: return 0.0 +def _decimal_string(value: Any, scale: int = 8) -> str: + try: + quant = Decimal("1").scaleb(-scale) + return format(Decimal(str(value or 0)).quantize(quant), "f") + except Exception: + return format(Decimal("0").quantize(Decimal("1").scaleb(-scale)), "f") + + def _as_date_start(value: str | None) -> datetime | None: if not value: return None try: - return datetime.strptime(value, "%Y-%m-%d") + return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=CST).astimezone(timezone.utc) except Exception: return None @@ -67,7 +77,11 @@ def _as_date_end(value: str | None) -> datetime | None: if not value: return None try: - return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999) + return ( + datetime.strptime(value, "%Y-%m-%d") + .replace(tzinfo=CST, hour=23, minute=59, second=59, microsecond=999999) + .astimezone(timezone.utc) + ) except Exception: return None @@ -92,6 +106,12 @@ def _build_filters( source_module: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, + engine_provider: str | None = None, + engine_model_name: str | None = None, + pricing_version_code: str | None = None, + provider_cost_status: str | None = None, + provider_cost_is_estimated: bool | None = None, + has_attachment: bool | None = None, start_date: str | None = None, end_date: str | None = None, ) -> list[Any]: @@ -107,10 +127,7 @@ def _build_filters( filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind) filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value) if team_id: - if team_id == TEAM_UNASSIGNED_VALUE: - filters.append(CreditRecord.team_id_snapshot.is_(None)) - else: - filters.append(CreditRecord.team_id_snapshot == team_id) + filters.append(CreditRecord.team_id_snapshot.is_(None) if team_id == TEAM_UNASSIGNED_VALUE else CreditRecord.team_id_snapshot == team_id) if record_type: filters.append(CreditRecord.type == record_type) if credit_subject: @@ -125,6 +142,18 @@ def _build_filters( filters.append(CreditRecord.source_step_code == source_step_code) if billing_scene: filters.append(CreditRecord.billing_scene == billing_scene) + if engine_provider: + filters.append(CreditRecord.engine_provider == engine_provider) + if engine_model_name: + filters.append(CreditRecord.engine_model_name.ilike(f"%{engine_model_name.strip()}%")) + if pricing_version_code: + filters.append(CreditRecord.pricing_version_code == pricing_version_code) + if provider_cost_status: + filters.append(CreditRecord.provider_cost_status == provider_cost_status) + if provider_cost_is_estimated is not None: + filters.append(CreditRecord.provider_cost_is_estimated.is_(provider_cost_is_estimated)) + if has_attachment is not None: + filters.append(CreditRecord.attachment_total_count > 0 if has_attachment else CreditRecord.attachment_total_count == 0) start = _as_date_start(start_date) end = _as_date_end(end_date) if start: @@ -139,7 +168,6 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di for record in records: if record.owner_type and record.owner_id: grouped.setdefault(record.owner_type, set()).add(record.owner_id) - model_map: dict[str, Any] = { "chat_generation_task": ChatGenerationTask, "generation_record": GenerationRecord, @@ -166,7 +194,6 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t owner_deleted_at = None if record.owner_type and record.owner_id: owner_deleted, owner_deleted_at = deleted_map.get((record.owner_type, record.owner_id), (False, None)) - user_type = record.user_type_snapshot or (user.user_type if user else None) frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None) return { @@ -219,6 +246,38 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t "engine_name": record.engine_name, "engine_provider": record.engine_provider, "engine_model_name": record.engine_model_name, + "pricing_rule_id": record.pricing_rule_id, + "pricing_version_code": record.pricing_version_code, + "pricing_billing_mode": record.pricing_billing_mode, + "pricing_billing_mode_label": _label(MODEL_PRICING_BILLING_MODE_LABELS, record.pricing_billing_mode), + "pricing_calculator_version": record.pricing_calculator_version, + "pricing_usage_source": record.pricing_usage_source, + "pricing_reference_at": _iso(record.pricing_reference_at), + "pricing_effective_from": _iso(record.pricing_effective_from), + "pricing_effective_to": _iso(record.pricing_effective_to), + "pricing_snapshot_hash": record.pricing_snapshot_hash, + "provider_cost_currency": record.provider_cost_currency or "CNY", + "provider_cost_amount": _decimal_string(record.provider_cost_amount, 8), + "provider_cost_status": record.provider_cost_status, + "provider_cost_status_label": _label(PROVIDER_COST_STATUS_LABELS, record.provider_cost_status), + "provider_cost_calculated_at": _iso(record.provider_cost_calculated_at), + "provider_cost_finalized_at": _iso(record.provider_cost_finalized_at), + "provider_cost_is_estimated": bool(record.provider_cost_is_estimated), + "provider_usage_primary": bool(record.provider_usage_primary), + "attachment_image_count": int(record.attachment_image_count or 0), + "attachment_video_count": int(record.attachment_video_count or 0), + "attachment_audio_count": int(record.attachment_audio_count or 0), + "attachment_total_count": int(record.attachment_total_count or 0), + "attachment_video_duration_seconds": _decimal_string(record.attachment_video_duration_seconds, 6), + "attachment_audio_duration_seconds": _decimal_string(record.attachment_audio_duration_seconds, 6), + "requested_output_count": int(record.requested_output_count or 0), + "generated_image_count": int(record.generated_image_count or 0), + "generated_video_count": int(record.generated_video_count or 0), + "generated_total_count": int(record.generated_total_count or 0), + "pricing_snapshot_json": record.pricing_snapshot_json, + "usage_snapshot_json": record.usage_snapshot_json, + "attachment_snapshot_json": record.attachment_snapshot_json, + "generation_snapshot_json": record.generation_snapshot_json, "created_at": _iso(record.created_at), } @@ -240,6 +299,12 @@ async def list_admin_credit_records( source_module: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, + engine_provider: str | None = None, + engine_model_name: str | None = None, + pricing_version_code: str | None = None, + provider_cost_status: str | None = None, + provider_cost_is_estimated: bool | None = None, + has_attachment: bool | None = None, start_date: str | None = None, end_date: str | None = None, ) -> dict[str, Any]: @@ -258,6 +323,12 @@ async def list_admin_credit_records( source_module=source_module, source_step_code=source_step_code, billing_scene=billing_scene, + engine_provider=engine_provider, + engine_model_name=engine_model_name, + pricing_version_code=pricing_version_code, + provider_cost_status=provider_cost_status, + provider_cost_is_estimated=provider_cost_is_estimated, + has_attachment=has_attachment, start_date=start_date, end_date=end_date, ) @@ -269,52 +340,81 @@ async def list_admin_credit_records( base_query = base_query.where(where_clause) count_query = count_query.where(where_clause) total = (await db.execute(count_query)).scalar() or 0 - - result = await db.execute( - base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - ) - rows = result.all() + rows = ( + await db.execute( + base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).all() records = [row[0] for row in rows] deleted_map = await _load_deleted_map(db, records) items = [_record_to_item(record, user, deleted_map) for record, user in rows] + media_consume = and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume") 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((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.coalesce(func.sum(CreditRecord.total_tokens), 0), - func.coalesce(func.sum(CreditRecord.input_tokens), 0), - func.coalesce(func.sum(CreditRecord.output_tokens), 0), + func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0).label("total_recharge"), + func.coalesce(func.sum(case((CreditRecord.type.in_(["consume", "team_internal"]), func.abs(CreditRecord.amount)), else_=0)), 0).label("total_consume"), + func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0).label("total_refund"), + func.count(CreditRecord.id).label("transaction_count"), + func.count(distinct(case((media_consume, func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("generation_count"), + func.count(case((media_consume, 1), else_=None)).label("generation_attempt_count"), + func.count(distinct(case((and_(media_consume, CreditRecord.media_type == "image"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("image_generation_count"), + func.count(distinct(case((and_(media_consume, CreditRecord.media_type == "video"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))).label("video_generation_count"), + func.coalesce(func.sum(case((and_(media_consume, CreditRecord.media_type == "image"), func.abs(CreditRecord.amount)), else_=0)), 0).label("image_consume"), + func.coalesce(func.sum(case((and_(media_consume, CreditRecord.media_type == "video"), func.abs(CreditRecord.amount)), else_=0)), 0).label("video_consume"), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0).label("text_consume"), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0).label("analysis_consume"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.total_tokens), else_=0)), 0).label("total_tokens"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.input_tokens), else_=0)), 0).label("input_tokens"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_usage_primary.is_(True)), CreditRecord.output_tokens), else_=0)), 0).label("output_tokens"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_image_count), else_=0)), 0).label("attachment_image_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_video_count), else_=0)), 0).label("attachment_video_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_audio_count), else_=0)), 0).label("attachment_audio_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.attachment_total_count), else_=0)), 0).label("attachment_total_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_image_count), else_=0)), 0).label("generated_image_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_video_count), else_=0)), 0).label("generated_video_count"), + func.coalesce(func.sum(case((CreditRecord.type == "consume", CreditRecord.generated_total_count), else_=0)), 0).label("generated_total_count"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "calculated"), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_calculated_total"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "estimated"), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_estimated_total"), + func.coalesce(func.sum(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status.in_(["calculated", "estimated"])), CreditRecord.provider_cost_amount), else_=0)), 0).label("provider_cost_combined_total"), + func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "pending"), 1), else_=None)).label("provider_cost_pending_count"), + func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status == "estimated"), 1), else_=None)).label("provider_cost_estimated_count"), + func.count(case((and_(CreditRecord.type == "consume", CreditRecord.provider_cost_status.in_(["unmatched_rule", "usage_missing", "error", "historical_price_unavailable", "historical_engine_unavailable", "provider_result_uncertain"])), 1), else_=None)).label("provider_cost_abnormal_count"), ).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True) if where_clause is not None: summary_query = summary_query.where(where_clause) - s = (await db.execute(summary_query)).one() + s = (await db.execute(summary_query)).one()._mapping summary = { - "total_recharge": _round2(s[0]), - "total_consume": _round2(s[1]), - "total_refund": _round2(s[2]), - "transaction_count": int(s[3] or 0), - "generation_count": int(s[4] or 0), - "generation_attempt_count": int(s[5] or 0), - "image_generation_count": int(s[6] or 0), - "video_generation_count": int(s[7] or 0), - "image_consume": _round2(s[8]), - "video_consume": _round2(s[9]), - "text_consume": _round2(s[10]), - "analysis_consume": _round2(s[11]), - "total_tokens": int(s[12] or 0), - "input_tokens": int(s[13] or 0), - "output_tokens": int(s[14] or 0), + "total_recharge": _round2(s["total_recharge"]), + "total_consume": _round2(s["total_consume"]), + "total_refund": _round2(s["total_refund"]), + "transaction_count": int(s["transaction_count"] or 0), + "generation_count": int(s["generation_count"] or 0), + "generation_attempt_count": int(s["generation_attempt_count"] or 0), + "image_generation_count": int(s["image_generation_count"] or 0), + "video_generation_count": int(s["video_generation_count"] or 0), + "image_consume": _round2(s["image_consume"]), + "video_consume": _round2(s["video_consume"]), + "text_consume": _round2(s["text_consume"]), + "analysis_consume": _round2(s["analysis_consume"]), + "total_tokens": int(s["total_tokens"] or 0), + "input_tokens": int(s["input_tokens"] or 0), + "output_tokens": int(s["output_tokens"] or 0), + "attachment_image_count": int(s["attachment_image_count"] or 0), + "attachment_video_count": int(s["attachment_video_count"] or 0), + "attachment_audio_count": int(s["attachment_audio_count"] or 0), + "attachment_total_count": int(s["attachment_total_count"] or 0), + "generated_image_count": int(s["generated_image_count"] or 0), + "generated_video_count": int(s["generated_video_count"] or 0), + "generated_total_count": int(s["generated_total_count"] or 0), + "provider_cost_calculated_total": _decimal_string(s["provider_cost_calculated_total"], 8), + "provider_cost_estimated_total": _decimal_string(s["provider_cost_estimated_total"], 8), + "provider_cost_combined_total": _decimal_string(s["provider_cost_combined_total"], 8), + # 兼容旧前端字段,值等于实际+估算;新页面不得标记成“已核算成本”。 + "provider_cost_total": _decimal_string(s["provider_cost_combined_total"], 8), + "provider_cost_pending_count": int(s["provider_cost_pending_count"] or 0), + "provider_cost_estimated_count": int(s["provider_cost_estimated_count"] or 0), + "provider_cost_abnormal_count": int(s["provider_cost_abnormal_count"] or 0), } - return {"items": items, "total": total, "summary": summary} + return {"items": items, "total": int(total), "summary": summary} 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..307866b5 100644 --- a/video-gen-api/app/services/credit_record_meta_service.py +++ b/video-gen-api/app/services/credit_record_meta_service.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import asdict, dataclass +from datetime import datetime +from decimal import Decimal from typing import Any, Mapping from sqlalchemy import select @@ -22,6 +24,8 @@ from app.models.module_generation_step import ModuleGenerationStep from app.models.team import Team from app.models.user import User from app.models.video_engine import VideoEngine +from app.services.model_pricing.attachment_snapshot_service import build_attachment_snapshot, parse_dimensions +from app.services.model_pricing.snapshot_service import build_refund_pricing_snapshot, enrich_credit_meta_with_pricing @dataclass(slots=True) @@ -56,6 +60,40 @@ class CreditRecordMeta: engine_provider: str | None = None engine_model_name: str | None = None + pricing_rule_id: str | None = None + pricing_version_code: str | None = None + pricing_billing_mode: str | None = None + pricing_calculator_version: str | None = None + pricing_usage_source: str | None = None + pricing_reference_at: datetime | None = None + pricing_effective_from: datetime | None = None + pricing_effective_to: datetime | None = None + pricing_snapshot_schema_version: int | None = None + pricing_snapshot_hash: str | None = None + provider_cost_currency: str | None = None + provider_cost_amount: Decimal | None = None + provider_cost_status: str | None = None + provider_cost_calculated_at: datetime | None = None + provider_cost_finalized_at: datetime | None = None + provider_usage_primary: bool | None = None + provider_cost_is_estimated: bool | None = None + + attachment_image_count: int | None = None + attachment_video_count: int | None = None + attachment_audio_count: int | None = None + attachment_total_count: int | None = None + attachment_video_duration_seconds: Decimal | None = None + attachment_audio_duration_seconds: Decimal | None = None + requested_output_count: int | None = None + generated_image_count: int | None = None + generated_video_count: int | None = None + generated_total_count: int | None = None + + pricing_snapshot_json: dict[str, Any] | None = None + usage_snapshot_json: dict[str, Any] | None = None + attachment_snapshot_json: dict[str, Any] | None = None + generation_snapshot_json: dict[str, Any] | None = None + user_type_snapshot: str | None = None frontend_user_kind_snapshot: str | None = None team_id_snapshot: str | None = None @@ -130,14 +168,29 @@ async def _apply_model_snapshot(db: AsyncSession, meta: CreditRecordMeta, usage: async def get_engine_snapshot(db: AsyncSession, *, gen_type: str, engine_id: str | None) -> dict[str, str | None]: - if not engine_id: - return {"engine_type": (gen_type or None)} + """锁定本次媒体扣费实际使用的引擎快照。 + + GenerationRecord 旧链路没有持久化 engine_id;此时必须与图片/视频生成服务保持一致, + 选择当前启用且 priority 最高的引擎。这样价格版本在请求扣费时就被锁定,不能等任务 + 完成后再按当时的活动引擎或最新价格回算。 + """ gen_type = (gen_type or "").lower().strip() if gen_type == "image": - result = await db.execute(select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)) + query = select(ImageEngine) + if engine_id: + query = query.where(ImageEngine.id == engine_id) + else: + query = query.where(ImageEngine.is_active == True).order_by(ImageEngine.priority.desc()) + elif gen_type == "video": + query = select(VideoEngine) + if engine_id: + query = query.where(VideoEngine.id == engine_id) + else: + query = query.where(VideoEngine.is_active == True).order_by(VideoEngine.priority.desc()) else: - result = await db.execute(select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)) - engine = result.scalar_one_or_none() + return {"engine_type": gen_type or None, "engine_id": engine_id} + + engine = (await db.execute(query.limit(1))).scalar_one_or_none() if not engine: return {"engine_type": gen_type or None, "engine_id": engine_id} return { @@ -222,6 +275,17 @@ async def build_generation_media_meta( source_step_id: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, + media_references: Any = None, + image_size: str | None = None, + image_px: str | None = None, + aspect_ratio: str | None = None, + duration: int | float | None = None, + resolution: str | None = None, + fps: int | float | None = None, + generate_audio: bool | None = None, + inference_mode: str | None = None, + input_video_duration: float | None = None, + requested_output_count: int = 1, ) -> CreditRecordMeta: media_type = (gen_type or "").lower().strip() or None if source_module is None: @@ -244,7 +308,58 @@ async def build_generation_media_meta( ) for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items(): setattr(meta, key, value) - return meta + requested_count = max(1, _safe_int(requested_output_count, 1)) + attachment_snapshot, attachment_counts = build_attachment_snapshot(media_references) + for key, value in attachment_counts.items(): + setattr(meta, key, value) + meta.attachment_snapshot_json = attachment_snapshot + + width, height = parse_dimensions(image_px, image_size) + dimension_source = "request_explicit" if width > 0 and height > 0 else "unavailable" + meta.requested_output_count = requested_count + meta.generated_image_count = 0 + meta.generated_video_count = 0 + meta.generated_total_count = 0 + meta.generation_snapshot_json = { + "schema_version": 1, + "gen_type": media_type, + "requested_output_count": requested_count, + "generated_image_count": 0, + "generated_video_count": 0, + "generated_total_count": 0, + "image_size": image_size, + "image_px": image_px, + "duration_seconds": float(duration or 0) or None, + "resolution": resolution, + "aspect_ratio": aspect_ratio, + "width": width or None, + "height": height or None, + "dimension_source": dimension_source, + "fps": float(fps or 0) or None, + "generate_audio": bool(generate_audio), + "inference_mode": inference_mode or "online", + "stage": "request_locked", + } + request_usage = { + "resolution": str(resolution or "").lower(), + "aspect_ratio": str(aspect_ratio or ""), + "output_width": width, + "output_height": height, + "dimension_source": dimension_source, + "output_video_duration_seconds": float(duration or 0), + "input_video_duration_seconds": float(input_video_duration or attachment_counts["attachment_video_duration_seconds"] or 0), + "input_audio_duration_seconds": float(attachment_counts["attachment_audio_duration_seconds"] or 0), + "provider_input_image_count": attachment_counts.get("provider_input_image_count", 0), + "input_image_count": attachment_counts.get("provider_input_image_count", 0), + "has_input_video": bool(attachment_counts.get("provider_input_video_count", 0) or input_video_duration), + "requested_output_count": requested_count, + "successful_output_count": 0, + "fps": float(fps or 0), + "generate_audio": bool(generate_audio), + "inference_mode": inference_mode or "online", + "usage_stage": "request_locked", + } + return await enrich_credit_meta_with_pricing(db, meta=meta, usage=request_usage, final=False) async def build_generation_record_prompt_meta( @@ -274,7 +389,8 @@ async def build_generation_record_prompt_meta( output_tokens=_safe_int(usage.get("output_tokens")), total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), ) - return await _apply_model_snapshot(db, meta, usage) + meta = await _apply_model_snapshot(db, meta, usage) + return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True) async def build_module_step_prompt_meta( @@ -305,7 +421,8 @@ async def build_module_step_prompt_meta( output_tokens=_safe_int(usage.get("output_tokens")), total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), ) - return await _apply_model_snapshot(db, meta, usage) + meta = await _apply_model_snapshot(db, meta, usage) + return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True) async def build_shot_video_analysis_meta( @@ -337,10 +454,12 @@ async def build_shot_video_analysis_meta( output_tokens=_safe_int(usage.get("output_tokens")), total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), ) - return await _apply_model_snapshot(db, meta, usage) + meta = await _apply_model_snapshot(db, meta, usage) + return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True) def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None) -> CreditRecordMeta: + pricing_snapshot, pricing_hash = build_refund_pricing_snapshot(charge) return CreditRecordMeta( owner_type=getattr(charge, "owner_type", None), owner_id=getattr(charge, "owner_id", None), @@ -363,6 +482,35 @@ def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None) engine_name=getattr(charge, "engine_name", None), engine_provider=getattr(charge, "engine_provider", None), engine_model_name=getattr(charge, "engine_model_name", None), + pricing_rule_id=getattr(charge, "pricing_rule_id", None), + pricing_version_code=getattr(charge, "pricing_version_code", None), + pricing_billing_mode=getattr(charge, "pricing_billing_mode", None), + pricing_calculator_version=getattr(charge, "pricing_calculator_version", None), + pricing_usage_source=getattr(charge, "pricing_usage_source", None), + pricing_reference_at=getattr(charge, "pricing_reference_at", None), + pricing_effective_from=getattr(charge, "pricing_effective_from", None), + pricing_effective_to=getattr(charge, "pricing_effective_to", None), + pricing_snapshot_schema_version=getattr(charge, "pricing_snapshot_schema_version", None), + pricing_snapshot_hash=pricing_hash, + provider_cost_currency=getattr(charge, "provider_cost_currency", None), + provider_cost_amount=Decimal("0"), + provider_cost_status="not_applicable", + provider_cost_is_estimated=False, + provider_usage_primary=False, + attachment_image_count=getattr(charge, "attachment_image_count", 0), + attachment_video_count=getattr(charge, "attachment_video_count", 0), + attachment_audio_count=getattr(charge, "attachment_audio_count", 0), + attachment_total_count=getattr(charge, "attachment_total_count", 0), + attachment_video_duration_seconds=getattr(charge, "attachment_video_duration_seconds", Decimal("0")), + attachment_audio_duration_seconds=getattr(charge, "attachment_audio_duration_seconds", Decimal("0")), + requested_output_count=getattr(charge, "requested_output_count", 0), + generated_image_count=getattr(charge, "generated_image_count", 0), + generated_video_count=getattr(charge, "generated_video_count", 0), + generated_total_count=getattr(charge, "generated_total_count", 0), + pricing_snapshot_json=pricing_snapshot, + usage_snapshot_json=getattr(charge, "usage_snapshot_json", None), + attachment_snapshot_json=getattr(charge, "attachment_snapshot_json", None), + generation_snapshot_json=getattr(charge, "generation_snapshot_json", None), user_type_snapshot=getattr(charge, "user_type_snapshot", None), frontend_user_kind_snapshot=getattr(charge, "frontend_user_kind_snapshot", None), ) diff --git a/video-gen-api/app/services/generation_ai_service.py b/video-gen-api/app/services/generation_ai_service.py index 4c731457..51204e7b 100644 --- a/video-gen-api/app/services/generation_ai_service.py +++ b/video-gen-api/app/services/generation_ai_service.py @@ -296,11 +296,14 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req record_id=task_id, gen_type="image", image_size=size, + image_px=px, + aspect_ratio=proportion, engine_id=engine.id, project_name="AI生成任务", description_prefix="AI创作-", owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, + media_references=refs, ) snapshot = _build_image_snapshot(engine, size, proportion, px) task = ChatGenerationTask( @@ -316,6 +319,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), + current_billing_attempt_no=1, media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=req.idempotency_key, @@ -392,12 +396,15 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req gen_type="video", duration=duration, resolution=resolution, + aspect_ratio=ratio, + fps=24, engine_id=engine.id, input_video_duration=input_video_duration if input_video_duration > 0 else None, project_name="AI生成任务", description_prefix="AI创作-", owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, + media_references=refs, ) snapshot = _build_video_snapshot(engine, ratio, resolution, duration) task = ChatGenerationTask( @@ -416,6 +423,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), + current_billing_attempt_no=1, media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=req.idempotency_key, diff --git a/video-gen-api/app/services/generation_billing_service.py b/video-gen-api/app/services/generation_billing_service.py index 9edd882c..d285b734 100644 --- a/video-gen-api/app/services/generation_billing_service.py +++ b/video-gen-api/app/services/generation_billing_service.py @@ -457,8 +457,13 @@ async def charge_generation_media_by_params( record_id: str, gen_type: str, image_size: str | None = None, + image_px: str | None = None, + aspect_ratio: str | None = None, duration: int | None = None, resolution: str | None = None, + fps: int | float | None = None, + generate_audio: bool | None = None, + inference_mode: str | None = None, engine_id: str | None = None, input_video_duration: float | None = None, project_name: str | None = None, @@ -470,6 +475,8 @@ async def charge_generation_media_by_params( source_step_id: str | None = None, source_step_code: str | None = None, billing_scene: str | None = None, + media_references: Any = None, + requested_output_count: int = 1, ) -> BillingSummary: """图片/视频媒体生成扣费。 @@ -504,6 +511,17 @@ async def charge_generation_media_by_params( source_step_id=source_step_id, source_step_code=source_step_code, billing_scene=billing_scene, + media_references=media_references, + image_size=image_size, + image_px=image_px, + aspect_ratio=aspect_ratio, + duration=duration, + resolution=resolution, + fps=fps, + generate_audio=generate_audio, + inference_mode=inference_mode, + input_video_duration=input_video_duration, + requested_output_count=requested_output_count, ) if gen_type == "image": @@ -561,6 +579,8 @@ async def charge_generation_media_for_record( record_id=record.id, gen_type=record.gen_type, image_size=record.image_size, + image_px=record.image_px, + aspect_ratio=record.aspect_ratio or record.image_proportion, duration=record.duration, resolution=record.resolution, project_name=project_name, @@ -568,4 +588,5 @@ async def charge_generation_media_for_record( owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, source_module=CreditRecordSourceModule.GENERATION_RECORD.value, + media_references=record.media_references, ) diff --git a/video-gen-api/app/services/generation_prompt_service.py b/video-gen-api/app/services/generation_prompt_service.py index f7719919..d64cd886 100644 --- a/video-gen-api/app/services/generation_prompt_service.py +++ b/video-gen-api/app/services/generation_prompt_service.py @@ -17,6 +17,7 @@ from app.models.token_usage import TokenUsage from app.services.generation_log_service import log_provider_call from app.services.provider_limit import provider_limit from app.utils.id_gen import generate_id +from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage def _absolute_url(url: str) -> str: @@ -180,7 +181,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask completion_tokens=output_tokens, total_tokens=total_tokens, ) - return content, { + return content, normalize_text_pricing_usage(usage, base={ "token_usage_id": token_usage_id, "model_config_id": config.id, "model_config_name": config.name, @@ -189,4 +190,4 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, - } + }) diff --git a/video-gen-api/app/services/generation_provider_service.py b/video-gen-api/app/services/generation_provider_service.py index f7b2731e..762d8e10 100644 --- a/video-gen-api/app/services/generation_provider_service.py +++ b/video-gen-api/app/services/generation_provider_service.py @@ -14,7 +14,7 @@ from app.models.chat_generation_task import ChatGenerationTask from app.models.image_engine import ImageEngine from app.models.video_engine import VideoEngine from app.services.generation_log_service import log_provider_call -from app.services.image_gen import poll_image_task_status, submit_image_task +from app.services.image_gen import submit_image_task from app.services.provider_limit import provider_limit from app.services.video_gen import poll_task_status, submit_video_task @@ -165,8 +165,7 @@ def _try_json(text: Any) -> Any: async def poll_provider_task(db: AsyncSession, task: ChatGenerationTask) -> dict: engine = await get_runtime_engine(db, task) task_id = task.seedance_task_id or task.provider_task_id - if task.gen_type == "video": - async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY): - return await poll_task_status(engine, task_id) - async with provider_limit("ark_image_poll", settings.ARK_IMAGE_POLL_MAX_CONCURRENCY): - return await poll_image_task_status(engine, task_id) + if task.gen_type != "video": + raise ValueError("当前火山图片引擎为同步生成,不允许进入 Provider 轮询链路") + async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY): + return await poll_task_status(engine, task_id) diff --git a/video-gen-api/app/services/generation_recovery_service.py b/video-gen-api/app/services/generation_recovery_service.py index 4692032e..bee6b7e9 100644 --- a/video-gen-api/app/services/generation_recovery_service.py +++ b/video-gen-api/app/services/generation_recovery_service.py @@ -17,6 +17,7 @@ from app.enums.generation_task import ( ChatGenerationTaskStatus, GenerationType, ) +from app.enums.model_pricing import ProviderCostStatus from app.models.chat_generation_task import ChatGenerationTask from app.services.celery_download_recovery_service import ( ensure_aware_utc, @@ -27,6 +28,8 @@ from app.services.celery_download_recovery_service import ( ) from app.services.generation_log_service import log_task_event from app.services.generation_module_hook_service import notify_chat_generation_task_finished +from app.services.media_token_usage_snapshot_service import mark_media_provider_cost_status +from app.services.model_pricing.usage_normalizer import extract_image_output_items from app.services.generation_poll_schedule_service import ensure_video_poll_fields, is_poll_not_due, is_video_generation_task from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.redis_registry_service import ( @@ -373,6 +376,46 @@ async def _mark_failed( return "mark_failed" +async def _mark_sync_image_provider_result_uncertain( + db: AsyncSession, + task: ChatGenerationTask, + *, + source: str, + payload: dict[str, Any] | None = None, +) -> str: + """Stop automatic replay after an interrupted synchronous image provider call.""" + attempt_no = task.current_billing_attempt_no + message = ( + "同步图片任务在供应商调用阶段中断,无法确认火山是否已经生成结果;" + "为避免重复生成和重复计费,已停止自动重放并退款,请结合供应商调用日志人工核查。" + ) + await mark_media_provider_cost_status( + db, + owner=task, + status=ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value, + reason=message, + usage_stage="provider_sync_recovery_uncertain", + ) + await log_task_event( + task, + event_type="PROVIDER_RESULT_UNCERTAIN", + message=message, + detail={ + "source": source, + "payload": payload or {}, + "attempt_no": attempt_no, + "pipeline_stage": task.pipeline_stage, + }, + ) + return await _mark_failed( + db, + task, + error_message=message, + event_type="PROVIDER_RESULT_UNCERTAIN", + detail={"source": source, "attempt_no": attempt_no}, + ) + + async def recover_one_generation_task( db: AsyncSession, task: ChatGenerationTask, @@ -411,6 +454,26 @@ async def recover_one_generation_task( has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip()) is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time)) + # 同步图片可能已经提交并保存了 Provider Response,但 remote_result_url 因旧数据或中断未写入。 + # 只从明确的 data 输出条目恢复,绝不递归扫描任意 URL。 + if task.gen_type == GenerationType.IMAGE.value and not has_remote_result and task.provider_response_json: + output_items = extract_image_output_items(task.provider_response_json) + recovered_url = next((str(item.get("url")) for item in output_items if item.get("url")), None) + if recovered_url: + task.remote_result_url = recovered_url + task.pipeline_stage = ChatGenerationPipelineStage.RESULT_READY.value + await db.commit() + await log_task_event( + task, + event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value, + message=f"{source} 从同步图片 Provider Response 恢复最终 URL,投递下载队列", + detail={"attempt_no": task.current_billing_attempt_no}, + ) + from app.tasks.generation_download_tasks import enqueue_download_task + + await enqueue_download_task(db, task, recover=True, reason=f"{source}_sync_image_response_recovered") + return "recover_sync_image_from_provider_response" + # 最高优先级:只要远程结果 URL 已经落库,说明生成侧已经成功。 # 不管当前 pipeline_stage 是 queued/creating/waiting/result_ready/download_*,恢复时都不能重复 create 或 poll。 if has_remote_result: @@ -524,21 +587,36 @@ async def recover_one_generation_task( ) return "recover_poll_has_provider_id" - # 未过 deadline,且没有结果 URL / 供应商任务 ID: - # 图片同步任务会重新进入 submit_image_task;视频/其它任务会重新创建供应商任务。 - # 这里不能投 poll,因为没有 provider_task_id/seedance_task_id 可查询。 + # 未过 deadline,且没有结果 URL / 供应商任务 ID。 + # queued/preparing 代表尚未开始 Provider 调用,可以安全重投;同步图片一旦进入 + # creating_provider_task/waiting_remote/polling/result_ready,结果可能已在供应商侧产生, + # 不能自动重放,否则可能产生第二次供应商费用。 + if task.gen_type == GenerationType.IMAGE.value and task.pipeline_stage in { + ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value, + ChatGenerationPipelineStage.WAITING_REMOTE.value, + ChatGenerationPipelineStage.POLLING.value, + ChatGenerationPipelineStage.RESULT_READY.value, + }: + await _remove_poll_active(task.id) + return await _mark_sync_image_provider_result_uncertain( + db, + task, + source=source, + payload=redis_payload, + ) + recoverable_create_stages = { ChatGenerationPipelineStage.QUEUED.value, ChatGenerationPipelineStage.PREPARING.value, ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value, ChatGenerationPipelineStage.WAITING_REMOTE.value, ChatGenerationPipelineStage.POLLING.value, + ChatGenerationPipelineStage.RESULT_READY.value, } if task.pipeline_stage in recoverable_create_stages: if task.pipeline_stage not in ( ChatGenerationPipelineStage.QUEUED.value, ChatGenerationPipelineStage.PREPARING.value, - ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value, ): task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value await db.commit() @@ -547,7 +625,7 @@ async def recover_one_generation_task( await log_task_event( task, event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value, - message=f"{source} 发现任务未超时且缺少 remote_result_url/供应商任务ID,恢复投递创建队列", + message=f"{source} 发现任务未超时且尚无可恢复的供应商结果,恢复投递创建队列", detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, ) chatapi_create_generation_task.apply_async( @@ -557,24 +635,6 @@ async def recover_one_generation_task( ) return "recover_create_no_remote_no_provider_before_deadline" - # result_ready 但没有 URL 是脏状态;未过 deadline 时回创建队列重新处理,过期上面已标记超时。 - if task.pipeline_stage == ChatGenerationPipelineStage.RESULT_READY.value: - task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value - await db.commit() - await _remove_poll_active(task.id) - await log_task_event( - task, - event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value, - message=f"{source} 发现 result_ready 但缺少 remote_result_url,未超时,恢复投递创建队列", - detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload}, - ) - chatapi_create_generation_task.apply_async( - args=[task.id], - queue=CeleryQueue.GEN_CHATAPI_CREATE.value, - countdown=0, - ) - return "recover_create_result_ready_no_url_before_deadline" - return f"skip_stage_{task.pipeline_stage}" diff --git a/video-gen-api/app/services/generation_task_factory_service.py b/video-gen-api/app/services/generation_task_factory_service.py index c492473e..f8cac80a 100644 --- a/video-gen-api/app/services/generation_task_factory_service.py +++ b/video-gen-api/app/services/generation_task_factory_service.py @@ -116,6 +116,8 @@ async def create_chat_generation_task_for_module( record_id=task_id, gen_type="image", image_size=size, + image_px=px, + aspect_ratio=proportion, engine_id=engine.id, project_name=billing_project_name, description_prefix=billing_description_prefix, @@ -126,6 +128,7 @@ async def create_chat_generation_task_for_module( source_step_id=billing_source_step_id, source_step_code=billing_source_step_code, billing_scene=billing_scene, + media_references=refs, ) snapshot = _build_image_snapshot(engine, size, proportion, px) task = ChatGenerationTask( @@ -142,6 +145,7 @@ async def create_chat_generation_task_for_module( pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), + current_billing_attempt_no=1, media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=backend_idempotency_key, @@ -170,6 +174,8 @@ async def create_chat_generation_task_for_module( gen_type="video", duration=selected_duration, resolution=selected_resolution, + aspect_ratio=ratio, + fps=24, engine_id=engine.id, project_name=billing_project_name, description_prefix=billing_description_prefix, @@ -180,6 +186,7 @@ async def create_chat_generation_task_for_module( source_step_id=billing_source_step_id, source_step_code=billing_source_step_code, billing_scene=billing_scene, + media_references=refs, ) snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration) task = ChatGenerationTask( @@ -199,6 +206,7 @@ async def create_chat_generation_task_for_module( pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), + current_billing_attempt_no=1, media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=backend_idempotency_key, 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 df1dd923..178f96e6 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 @@ -27,6 +27,7 @@ from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSc from app.models.model_config import ModelConfig from app.models.token_usage import TokenUsage from app.utils.id_gen import generate_id +from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage from app.services.resource_signed_url_service import build_resource_signed_url DEFAULT_FRAME_RATE = "30fps" @@ -1675,12 +1676,12 @@ async def optimize_hot_opening_video_prompt( ) raise usage = data.get("usage", {}) or {} - token_usage = { + token_usage = normalize_text_pricing_usage(usage, base={ "input_tokens": int(usage.get("prompt_tokens") or 0), "output_tokens": int(usage.get("completion_tokens") or 0), "total_tokens": int(usage.get("total_tokens") or 0), "log_user_message": log_user_message, - } + }) token_usage_id = generate_id() db.add( TokenUsage( diff --git a/video-gen-api/app/services/image_gen.py b/video-gen-api/app/services/image_gen.py index 52679708..ede4f779 100644 --- a/video-gen-api/app/services/image_gen.py +++ b/video-gen-api/app/services/image_gen.py @@ -72,6 +72,24 @@ def _log_image_response(record_id: str, response_data: dict, error: str | None = +def is_sync_image_provider_result_uncertain(exc: BaseException) -> bool: + """Whether replaying the synchronous image request could duplicate provider cost.""" + if isinstance(exc, (TimeoutError, httpx.TimeoutException, httpx.TransportError)): + return True + text = str(exc or "").strip().lower() + markers = ( + "timeout", + "timed out", + "connection reset", + "connection aborted", + "server disconnected", + "remote protocol", + "read error", + "network is unreachable", + ) + return any(marker in text for marker in markers) + + async def get_active_image_engine(db: AsyncSession) -> ImageEngine: """Get the active image engine with highest priority.""" result = await db.execute( @@ -112,7 +130,7 @@ def submit_image_task( ) -> dict: """Submit an image generation task via Ark SDK. Returns image_url.""" from volcenginesdkarkruntime import Ark - + client = Ark( base_url=engine.api_base, api_key=engine.api_key, @@ -161,16 +179,23 @@ def submit_image_task( image=image_urls if image_urls else None, ) image_url = result.data[0].url - + response_data = { "model": result.model, "created": result.created, "data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [], "usage": { - "generated_images": result.usage.generated_images if hasattr(result.usage, 'generated_images') else 0, - "output_tokens": result.usage.output_tokens if hasattr(result.usage, 'output_tokens') else 0, - "total_tokens": result.usage.total_tokens if hasattr(result.usage, 'total_tokens') else 0, - } + "generated_images": result.usage.generated_images if getattr(result, "usage", None) and hasattr(result.usage, "generated_images") else len(result.data or []), + "input_tokens": result.usage.input_tokens if getattr(result, "usage", None) and hasattr(result.usage, "input_tokens") else 0, + "output_tokens": result.usage.output_tokens if getattr(result, "usage", None) and hasattr(result.usage, "output_tokens") else 0, + "total_tokens": result.usage.total_tokens if getattr(result, "usage", None) and hasattr(result.usage, "total_tokens") else 0, + }, + "pricing_meta": { + "provider_input_image_count": len(image_urls), + "requested_output_count": 1, + "requested_size": record.image_px or record.image_size or engine.default_size, + "sync_completed": True, + }, } except httpx.TimeoutException: error_msg = "图片生成超时,请稍后重试" @@ -184,12 +209,12 @@ def submit_image_task( raise finally: client.close() - + return { "image_url": image_url, - "image_tokens": getattr(result.usage, "total_tokens", 0), + "image_tokens": getattr(getattr(result, "usage", None), "total_tokens", 0), "response_data": json.dumps(response_data, ensure_ascii=False, default=str), - "error": str(result.error) if result.error else "", + "error": str(getattr(result, "error", "") or ""), } diff --git a/video-gen-api/app/services/llm.py b/video-gen-api/app/services/llm.py index 41c71dcc..026107b0 100644 --- a/video-gen-api/app/services/llm.py +++ b/video-gen-api/app/services/llm.py @@ -13,6 +13,7 @@ from app.config import settings from app.models.model_config import ModelConfig from app.models.token_usage import TokenUsage from app.utils.id_gen import generate_id +from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data @@ -385,7 +386,7 @@ async def _call_openai_compatible( await db.flush() content = data["choices"][0]["message"]["content"].strip() - token_usage = { + token_usage = normalize_text_pricing_usage(usage, base={ "token_usage_id": token_usage_id, "model_config_id": config.id, "model_config_name": config.name, @@ -394,5 +395,5 @@ async def _call_openai_compatible( "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, - } + }) return content, token_usage diff --git a/video-gen-api/app/services/media_token_usage_snapshot_service.py b/video-gen-api/app/services/media_token_usage_snapshot_service.py index 9bfef50a..9167c1a8 100644 --- a/video-gen-api/app/services/media_token_usage_snapshot_service.py +++ b/video-gen-api/app/services/media_token_usage_snapshot_service.py @@ -1,89 +1,67 @@ from __future__ import annotations -import json -from typing import Any, Mapping +from copy import deepcopy +from datetime import datetime, timezone +from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.enums.credit_record import ( - CreditRecordAction, - CreditRecordChargeKind, - CreditRecordOwnerType, - CreditRecordSourceModule, -) +from app.enums.credit_record import CreditRecordAction, CreditRecordChargeKind, CreditRecordOwnerType +from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus from app.models.chat_generation_task import ChatGenerationTask from app.models.credit_record import CreditRecord from app.models.generation_record import GenerationRecord from app.models.token_usage import TokenUsage +from app.services.model_pricing.attachment_snapshot_service import build_attachment_snapshot, build_generation_snapshot +from app.services.model_pricing.usage_normalizer import ( + normalize_provider_media_usage, + safe_int, + safe_json_dict, +) +from app.services.model_pricing.snapshot_service import finalize_credit_record_pricing +from app.services.operation_log_service import log_model_pricing_event from app.utils.id_gen import generate_id -def _safe_int(value: Any, default: int = 0) -> int: - try: - if value is None or value == "": - return default - return int(value) - except Exception: - return default +def _provider_model_from_response(provider_response: Any) -> str | None: + data = safe_json_dict(provider_response) + for candidate in ( + data.get("model"), + (data.get("data") or {}).get("model") if isinstance(data.get("data"), dict) else None, + (data.get("result") or {}).get("model") if isinstance(data.get("result"), dict) else None, + ): + if candidate: + return str(candidate) + return None -def _safe_json_dict(value: Any) -> dict[str, Any]: - if not value: - return {} - if isinstance(value, dict): - return value - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} +def _provider_task_id_from_response(provider_response: Any) -> str | None: + data = safe_json_dict(provider_response) + for candidate in (data.get("task_id"), data.get("id"), data.get("provider_task_id")): + if candidate: + return str(candidate) + return None -def _extract_usage(provider_response: Any) -> dict[str, Any]: - data = _safe_json_dict(provider_response) - usage = data.get("usage") - return usage if isinstance(usage, dict) else {} +def _engine_snapshot_from_owner(owner: Any) -> dict[str, Any]: + snapshot = safe_json_dict(getattr(owner, "engine_snapshot_json", None)) + return { + "provider": snapshot.get("provider") or snapshot.get("engine_provider"), + "model_name": snapshot.get("model_name") or snapshot.get("engine_model_name"), + "engine_name": snapshot.get("name") or snapshot.get("engine_name"), + "engine_id": snapshot.get("id") or snapshot.get("engine_id") or getattr(owner, "engine_id", None), + } -def _normalize_media_tokens( - *, - gen_type: str | None, - provider_response: Any = None, - fallback_total: int | None = None, -) -> tuple[int, int, int]: - usage = _extract_usage(provider_response) - input_tokens = _safe_int(usage.get("input_tokens"), 0) - output_tokens = _safe_int( - usage.get("output_tokens"), - _safe_int(usage.get("generated_tokens"), 0), - ) - total_tokens = _safe_int(usage.get("total_tokens"), 0) - - if total_tokens <= 0: - total_tokens = _safe_int(fallback_total, 0) - if output_tokens <= 0: - output_tokens = max(0, total_tokens - input_tokens) - if total_tokens <= 0: - total_tokens = input_tokens + output_tokens - - # 图片生成多数供应商只返回 output/total,没有 input;保持 input=0。视频同理兼容缺字段。 - return input_tokens, output_tokens, total_tokens - - -def _engine_model_from_provider_response(provider_response: Any) -> str | None: - data = _safe_json_dict(provider_response) - model = data.get("model") - return str(model) if model else None - - -async def _find_latest_media_charge( +async def _find_media_charge( db: AsyncSession, *, user_id: str, owner_type: str, owner_id: str, + attempt_no: int | None, media_type: str | None, ) -> CreditRecord | None: query = ( @@ -97,9 +75,17 @@ async def _find_latest_media_charge( ) if media_type: query = query.where(CreditRecord.media_type == media_type) - query = query.order_by(CreditRecord.attempt_no.desc().nullslast(), CreditRecord.created_at.desc()).limit(1) - result = await db.execute(query) - return result.scalar_one_or_none() + if attempt_no is not None: + query = query.where(CreditRecord.attempt_no == attempt_no) + query = query.order_by(CreditRecord.created_at.desc()).limit(1).with_for_update() + return (await db.execute(query)).scalar_one_or_none() + + # 兼容迁移上线时仍在执行、尚未写 current_billing_attempt_no 的旧任务: + # 只有候选消费流水唯一时才允许绑定;多次重试产生多条流水时宁可跳过,也不能猜“最新一条”。 + candidates = ( + await db.execute(query.order_by(CreditRecord.created_at.desc()).limit(2).with_for_update()) + ).scalars().all() + return candidates[0] if len(candidates) == 1 else None async def _get_or_create_token_usage( @@ -109,20 +95,21 @@ async def _get_or_create_token_usage( input_tokens: int, output_tokens: int, total_tokens: int, - model_config_id: str | None = None, ) -> TokenUsage: token_usage: TokenUsage | None = None if charge.token_usage_id: - result = await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1)) - token_usage = result.scalar_one_or_none() + token_usage = ( + await db.execute(select(TokenUsage).where(TokenUsage.id == charge.token_usage_id).limit(1)) + ).scalar_one_or_none() if token_usage is None and charge.biz_key: - result = await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1)) - token_usage = result.scalar_one_or_none() + token_usage = ( + await db.execute(select(TokenUsage).where(TokenUsage.biz_key == charge.biz_key).limit(1)) + ).scalar_one_or_none() if token_usage is None: token_usage = TokenUsage( id=generate_id(), user_id=charge.user_id, - model_config_id=model_config_id, + model_config_id=None, owner_type=charge.owner_type, owner_id=charge.owner_id, biz_key=charge.biz_key, @@ -135,13 +122,6 @@ async def _get_or_create_token_usage( db.add(token_usage) await db.flush() else: - token_usage.user_id = token_usage.user_id or charge.user_id - token_usage.model_config_id = token_usage.model_config_id or model_config_id - token_usage.owner_type = token_usage.owner_type or charge.owner_type - token_usage.owner_id = token_usage.owner_id or charge.owner_id - token_usage.biz_key = token_usage.biz_key or charge.biz_key - token_usage.source_module = token_usage.source_module or charge.source_module - token_usage.source_step_code = token_usage.source_step_code or charge.source_step_code token_usage.input_tokens = input_tokens token_usage.output_tokens = output_tokens token_usage.total_tokens = total_tokens @@ -152,39 +132,150 @@ async def _sync_charge_snapshot( db: AsyncSession, *, charge: CreditRecord | None, - gen_type: str | None, + owner: Any, + gen_type: str, + stage: str, provider_response: Any = None, - fallback_total: int | None = None, + fallback_total: int = 0, ) -> CreditRecord | None: if not charge: + log_model_pricing_event( + event_type="pricing_snapshot_skip", + event_status="warning", + owner_type=owner.__class__.__name__, + owner_id=getattr(owner, "id", None), + message="未找到与 current_billing_attempt_no 匹配的媒体消费流水", + detail={"attempt_no": getattr(owner, "current_billing_attempt_no", None), "stage": stage}, + ) return None - input_tokens, output_tokens, total_tokens = _normalize_media_tokens( + response = provider_response if provider_response is not None else getattr(owner, "provider_response_json", None) + attachment_snapshot, attachment_counts = build_attachment_snapshot(getattr(owner, "media_references", None)) + generation_snapshot, generation_counts, generation_usage = build_generation_snapshot( + owner, + provider_response=response, + stage=stage, + ) + existing_usage = deepcopy(dict(charge.usage_snapshot_json or {})) + provider_usage = normalize_provider_media_usage( + response, gen_type=gen_type, - provider_response=provider_response, - fallback_total=fallback_total, + fallback_total_tokens=fallback_total, + request_image_px=getattr(owner, "image_px", None), + requested_output_count=max(1, safe_int(generation_counts.get("requested_output_count"), 1)), + provider_input_image_count=safe_int(attachment_counts.get("provider_input_image_count")), + ) + usage = {**existing_usage, **generation_usage, **provider_usage} + usage.update( + { + "has_input_video": safe_int(attachment_counts.get("provider_input_video_count")) > 0, + "provider_input_image_count": safe_int( + provider_usage.get("provider_input_image_count"), + safe_int(attachment_counts.get("provider_input_image_count")), + ), + "input_image_count": safe_int( + provider_usage.get("provider_input_image_count"), + safe_int(attachment_counts.get("provider_input_image_count")), + ), + "input_video_duration_seconds": float(attachment_counts.get("attachment_video_duration_seconds") or 0), + "input_audio_duration_seconds": float(attachment_counts.get("attachment_audio_duration_seconds") or 0), + "usage_stage": stage, + } ) - if total_tokens <= 0: - return charge - token_usage = await _get_or_create_token_usage( + input_tokens = max(0, safe_int(usage.get("input_tokens"))) + output_tokens = max(0, safe_int(usage.get("output_tokens"))) + total_tokens = max(0, safe_int(usage.get("total_tokens"), input_tokens + output_tokens)) + if total_tokens > 0: + token_usage = await _get_or_create_token_usage( + db, + charge=charge, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + ) + charge.token_usage_id = token_usage.id + charge.input_tokens = input_tokens + charge.output_tokens = output_tokens + charge.total_tokens = total_tokens + + provider_model = _provider_model_from_response(response) + engine_snapshot = _engine_snapshot_from_owner(owner) + charge.engine_provider = charge.engine_provider or engine_snapshot.get("provider") + charge.engine_model_name = charge.engine_model_name or provider_model or engine_snapshot.get("model_name") + charge.engine_name = charge.engine_name or engine_snapshot.get("engine_name") + charge.engine_id = charge.engine_id or engine_snapshot.get("engine_id") + + await finalize_credit_record_pricing( db, charge=charge, - input_tokens=input_tokens, - output_tokens=output_tokens, - total_tokens=total_tokens, - model_config_id=None, + usage=usage, + stage=stage, + attachment_snapshot=attachment_snapshot, + attachment_counts=attachment_counts, + generation_snapshot=generation_snapshot, + generation_counts=generation_counts, + allow_upgrade_estimated=True, ) + return charge - charge.token_usage_id = token_usage.id - charge.input_tokens = input_tokens - charge.output_tokens = output_tokens - charge.total_tokens = total_tokens - # 兼容旧流水扣费时未冷备 engine_model_name 的场景,能从 provider response 推出来就补充。 - provider_model = _engine_model_from_provider_response(provider_response) - if provider_model and not charge.engine_model_name: - charge.engine_model_name = provider_model +async def mark_media_provider_cost_status( + db: AsyncSession, + *, + owner: ChatGenerationTask | GenerationRecord, + status: str, + reason: str, + usage_stage: str, +) -> CreditRecord | None: + """Finalize a media charge when a synchronous provider call failed or became uncertain. + + This helper never commits and always resolves the charge by owner + billing attempt. + """ + if isinstance(owner, ChatGenerationTask): + owner_type = CreditRecordOwnerType.CHAT_GENERATION_TASK.value + else: + owner_type = CreditRecordOwnerType.GENERATION_RECORD.value + gen_type = str(getattr(owner, "gen_type", None) or "").lower().strip() + charge = await _find_media_charge( + db, + user_id=owner.user_id, + owner_type=owner_type, + owner_id=owner.id, + attempt_no=getattr(owner, "current_billing_attempt_no", None), + media_type=gen_type or None, + ) + if not charge: + log_model_pricing_event( + event_type="pricing_snapshot_skip", + event_status="warning", + owner_type=owner_type, + owner_id=owner.id, + message="同步图片异常时未找到唯一媒体消费流水", + detail={ + "attempt_no": getattr(owner, "current_billing_attempt_no", None), + "target_status": status, + "reason": reason, + }, + ) + return None + if charge.provider_cost_status in {ProviderCostStatus.CALCULATED.value, ProviderCostStatus.ESTIMATED.value}: + return charge + + usage_snapshot = deepcopy(dict(charge.usage_snapshot_json or {})) + usage_snapshot.update( + { + "usage_stage": usage_stage, + "provider_error_reason": reason, + "provider_result_uncertain": status == ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value, + } + ) + charge.usage_snapshot_json = usage_snapshot + charge.provider_cost_status = status + charge.provider_cost_amount = None if status == ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value else 0 + charge.provider_cost_is_estimated = False + charge.provider_cost_calculated_at = datetime.now(timezone.utc) + charge.provider_cost_finalized_at = datetime.now(timezone.utc) return charge @@ -193,33 +284,48 @@ async def sync_chat_generation_task_media_token_snapshot( task: ChatGenerationTask, *, provider_response: Any = None, + stage: str | None = None, ) -> CreditRecord | None: - """把 ChatGenerationTask 图片/视频媒体生成 token 后置快照回填到积分流水。 - - 媒体扣费发生在创建任务前,供应商 usage 只能在创建/轮询成功后拿到, - 所以这里按 owner_type + owner_id + media_type 找到对应 media charge 流水并回填。 - """ - if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)): + if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)) or not task: return None - if not task: + gen_type = (task.gen_type or "").lower().strip() + stage = stage or ( + PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value + if gen_type == "image" + else PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value + ) + response = provider_response if provider_response is not None else task.provider_response_json + + callback_task_id = _provider_task_id_from_response(response) + current_task_id = task.seedance_task_id or task.provider_task_id + if gen_type == "video" and callback_task_id and current_task_id and callback_task_id != current_task_id: + log_model_pricing_event( + event_type="pricing_stale_callback_skip", + event_status="warning", + owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value, + owner_id=task.id, + message="旧 Provider 回调与当前任务 ID 不一致,已跳过", + detail={"callback_task_id": callback_task_id, "current_task_id": current_task_id}, + ) return None - gen_type = (getattr(task, "gen_type", None) or "").lower().strip() - fallback_total = task.image_tokens_used if gen_type == "image" else task.video_tokens_used - response = provider_response if provider_response is not None else getattr(task, "provider_response_json", None) - charge = await _find_latest_media_charge( + charge = await _find_media_charge( db, user_id=task.user_id, owner_type=CreditRecordOwnerType.CHAT_GENERATION_TASK.value, owner_id=task.id, + attempt_no=task.current_billing_attempt_no, media_type=gen_type or None, ) + fallback_total = task.image_tokens_used if gen_type == "image" else task.video_tokens_used return await _sync_charge_snapshot( db, charge=charge, + owner=task, gen_type=gen_type, + stage=stage, provider_response=response, - fallback_total=fallback_total, + fallback_total=fallback_total or 0, ) @@ -228,26 +334,32 @@ async def sync_generation_record_media_token_snapshot( record: GenerationRecord, *, provider_response: Any = None, + stage: str | None = None, ) -> CreditRecord | None: - """把旧 GenerationRecord 图片/视频媒体生成 token 后置快照回填到积分流水。""" - if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)): + if not bool(getattr(settings, "MEDIA_TOKEN_SNAPSHOT_ENABLED", True)) or not record: return None - if not record: - return None - - gen_type = (getattr(record, "gen_type", None) or "").lower().strip() - fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used - charge = await _find_latest_media_charge( + gen_type = (record.gen_type or "").lower().strip() + stage = stage or ( + PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value + if gen_type == "image" + else PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value + ) + response = provider_response if provider_response is not None else record.provider_response_json + charge = await _find_media_charge( db, user_id=record.user_id, owner_type=CreditRecordOwnerType.GENERATION_RECORD.value, owner_id=record.id, + attempt_no=record.current_billing_attempt_no, media_type=gen_type or None, ) + fallback_total = record.image_tokens_used if gen_type == "image" else record.video_tokens_used return await _sync_charge_snapshot( db, charge=charge, + owner=record, gen_type=gen_type, - provider_response=provider_response, - fallback_total=fallback_total, + stage=stage, + provider_response=response, + fallback_total=fallback_total or 0, ) diff --git a/video-gen-api/app/services/model_pricing/__init__.py b/video-gen-api/app/services/model_pricing/__init__.py new file mode 100644 index 00000000..02f6c352 --- /dev/null +++ b/video-gen-api/app/services/model_pricing/__init__.py @@ -0,0 +1,7 @@ +"""模型计价服务软包。 + +各调用方显式导入 calculator/rule_service/snapshot_service,避免导入纯计算器时 +提前初始化数据库引擎,降低模块耦合并便于离线测试。 +""" + +__all__: list[str] = [] diff --git a/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py b/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py new file mode 100644 index 00000000..df67b6fc --- /dev/null +++ b/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import hashlib +import json +import re +from decimal import Decimal +from typing import Any, Iterable, Mapping +from urllib.parse import urlsplit, urlunsplit + +from app.services.model_pricing.usage_normalizer import ( + extract_image_output_items, + parse_size, + safe_bool, + safe_float, + safe_int, + safe_json_dict, + sanitize_output_items, +) + + +MEDIA_TYPES = {"image", "video", "audio"} + + +def _safe_json(value: Any) -> Any: + if value in (None, ""): + return None + if isinstance(value, (dict, list)): + return value + if isinstance(value, str): + try: + return json.loads(value) + except Exception: + return value + return value + + +def _normalize_url(value: str | None) -> tuple[str | None, str | None]: + if not value: + return None, None + text = str(value).strip() + try: + parts = urlsplit(text) + if parts.scheme and parts.netloc: + normalized = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", "")) + else: + normalized = text.split("?", 1)[0].split("#", 1)[0] + except Exception: + normalized = text.split("?", 1)[0].split("#", 1)[0] + normalized = normalized[:1024] + digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + return normalized, digest + + +def _guess_media_type(item: Mapping[str, Any]) -> str | None: + value = str(item.get("type") or item.get("media_type") or item.get("resource_type") or "").lower().strip() + if value in MEDIA_TYPES: + return value + url = str(item.get("url") or item.get("path") or item.get("display_url") or "").lower() + if re.search(r"\.(png|jpe?g|webp|gif|bmp)(?:\?|$)", url): + return "image" + if re.search(r"\.(mp4|mov|m4v|webm|avi|mkv)(?:\?|$)", url): + return "video" + if re.search(r"\.(mp3|wav|aac|m4a|flac|ogg)(?:\?|$)", url): + return "audio" + return None + + +def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]: + parsed = _safe_json(value) + if isinstance(parsed, list): + for item in parsed: + yield from _walk_reference_items(item) + return + if isinstance(parsed, dict): + if _guess_media_type(parsed) or any(k in parsed for k in ("url", "path", "resource_id", "private_asset_id")): + yield parsed + return + for child in parsed.values(): + if isinstance(child, (dict, list, str)): + yield from _walk_reference_items(child) + + +def _billable_input(raw: Mapping[str, Any], media_type: str) -> bool: + if "billable_input" in raw: + return safe_bool(raw.get("billable_input"), True) + role = str(raw.get("role") or raw.get("label") or raw.get("reference_role") or "").lower() + if role in {"cover", "preview", "display_only", "generated_output", "output"}: + return False + return media_type in MEDIA_TYPES + + +def build_attachment_snapshot(media_references: Any) -> tuple[dict[str, Any], dict[str, Any]]: + items: list[dict[str, Any]] = [] + image_count = video_count = audio_count = 0 + provider_input_image_count = 0 + provider_input_video_count = 0 + provider_input_audio_count = 0 + video_duration = Decimal("0") + audio_duration = Decimal("0") + seen: set[str] = set() + + for raw in _walk_reference_items(media_references): + media_type = _guess_media_type(raw) + if media_type not in MEDIA_TYPES: + continue + raw_url = raw.get("url") or raw.get("path") or raw.get("display_url") or raw.get("preview_url") + safe_url, url_hash = _normalize_url(str(raw_url) if raw_url else None) + identity = str( + raw.get("resource_id") + or raw.get("upload_resource_id") + or raw.get("private_asset_id") + or url_hash + or f"{media_type}:{len(items)}" + ) + dedupe_key = f"{media_type}:{identity}" + if dedupe_key in seen: + continue + seen.add(dedupe_key) + + duration = max(0.0, safe_float(raw.get("duration"), safe_float(raw.get("duration_seconds")))) + billable = _billable_input(raw, media_type) + item = { + "type": media_type, + "role": raw.get("role") or raw.get("label") or raw.get("reference_role"), + "source": raw.get("source"), + "billable_input": billable, + "resource_id": raw.get("resource_id") or raw.get("upload_resource_id"), + "private_asset_id": raw.get("private_asset_id"), + "name": raw.get("name") or raw.get("filename"), + "duration_seconds": duration or None, + "file_size": safe_int(raw.get("file_size"), safe_int(raw.get("size"))) or None, + "safe_url": safe_url, + "url_sha256": url_hash, + } + items.append({k: v for k, v in item.items() if v is not None}) + if media_type == "image": + image_count += 1 + provider_input_image_count += int(billable) + elif media_type == "video": + video_count += 1 + provider_input_video_count += int(billable) + video_duration += Decimal(str(duration)) + else: + audio_count += 1 + provider_input_audio_count += int(billable) + audio_duration += Decimal(str(duration)) + + counts = { + "attachment_image_count": image_count, + "attachment_video_count": video_count, + "attachment_audio_count": audio_count, + "attachment_total_count": image_count + video_count + audio_count, + "attachment_video_duration_seconds": video_duration, + "attachment_audio_duration_seconds": audio_duration, + "provider_input_image_count": provider_input_image_count, + "provider_input_video_count": provider_input_video_count, + "provider_input_audio_count": provider_input_audio_count, + } + snapshot = { + "schema_version": 1, + "items": items, + "counts": { + "image": image_count, + "video": video_count, + "audio": audio_count, + "total": image_count + video_count + audio_count, + "provider_input_image": provider_input_image_count, + "provider_input_video": provider_input_video_count, + "provider_input_audio": provider_input_audio_count, + }, + "durations": { + "video_seconds": str(video_duration), + "audio_seconds": str(audio_duration), + }, + } + return snapshot, counts + + +def parse_dimensions(*values: Any, resolution: str | None = None, aspect_ratio: str | None = None) -> tuple[int, int]: + """仅解析明确像素;resolution/aspect_ratio 不再映射为猜测尺寸。""" + del resolution, aspect_ratio + for value in values: + width, height = parse_size(value) + if width > 0 and height > 0: + return width, height + return 0, 0 + + +def _engine_snapshot(owner: Any) -> dict[str, Any]: + return safe_json_dict(getattr(owner, "engine_snapshot_json", None)) + + +def build_generation_snapshot( + owner: Any, + *, + provider_response: Any = None, + stage: str | None = None, +) -> tuple[dict[str, Any], dict[str, int], dict[str, Any]]: + """构建请求/Provider/资源快照。 + + 图片只读取同步接口明确的 data 输出条目;不会递归扫描 provider response 中的通用 URL。 + """ + gen_type = str(getattr(owner, "gen_type", None) or getattr(owner, "media_type", None) or "").lower().strip() + response = safe_json_dict(provider_response if provider_response is not None else getattr(owner, "provider_response_json", None)) + engine_snapshot = _engine_snapshot(owner) + aspect_ratio = getattr(owner, "aspect_ratio", None) or getattr(owner, "image_proportion", None) + resolution = getattr(owner, "resolution", None) + + width, height = parse_dimensions( + response.get("size"), + getattr(owner, "image_px", None), + engine_snapshot.get("selected_px"), + engine_snapshot.get("image_px"), + ) + dimension_source = "unavailable" + if parse_dimensions(response.get("size")) != (0, 0): + dimension_source = "provider_response" + elif parse_dimensions(getattr(owner, "image_px", None)) != (0, 0): + dimension_source = "request_explicit" + elif parse_dimensions(engine_snapshot.get("selected_px"), engine_snapshot.get("image_px")) != (0, 0): + dimension_source = "engine_snapshot" + + output_items: list[dict[str, Any]] = [] + generated_image_count = 0 + generated_video_count = 0 + if gen_type == "image": + output_items = extract_image_output_items(response) + if width <= 0 or height <= 0: + first_sized = next( + (item for item in output_items if safe_int(item.get("width")) > 0 and safe_int(item.get("height")) > 0), + None, + ) + if first_sized: + width = safe_int(first_sized.get("width")) + height = safe_int(first_sized.get("height")) + dimension_source = "provider_response" + if width > 0 and height > 0: + for item in output_items: + if not item.get("width") or not item.get("height"): + item.update( + { + "width": width, + "height": height, + "pixels": width * height, + "size_source": dimension_source, + } + ) + output_items = sanitize_output_items(output_items) + generated_image_count = len(output_items) + if generated_image_count == 0 and stage == "resource_download_completed" and getattr(owner, "image_url", None): + generated_image_count = 1 + output_items = sanitize_output_items( + [{"index": 0, "url": getattr(owner, "image_url"), "size_source": "resource_snapshot"}] + ) + elif gen_type == "video": + video_url = response.get("video_url") or response.get("url") + if stage == "resource_download_completed": + video_url = getattr(owner, "video_url", None) or video_url + generated_video_count = 1 if video_url else 0 + if video_url: + output_items = sanitize_output_items([{"index": 0, "url": video_url, "type": "video"}]) + + pricing_meta = response.get("pricing_meta") if isinstance(response.get("pricing_meta"), Mapping) else {} + requested_output_count = max( + 1, + safe_int( + pricing_meta.get("requested_output_count"), + safe_int(getattr(owner, "output_count", None), safe_int(getattr(owner, "count", None), 1)), + ), + ) + output_duration = max(0.0, safe_float(getattr(owner, "duration", None))) + fps = max(0.0, safe_float(getattr(owner, "fps", None), safe_float(getattr(owner, "frame_rate", None)))) + generate_audio = safe_bool(getattr(owner, "generate_audio", None), safe_bool(response.get("generate_audio"))) + inference_mode = str( + getattr(owner, "inference_mode", None) + or getattr(owner, "service_tier", None) + or response.get("service_tier") + or "online" + ).lower() + + counts = { + "requested_output_count": requested_output_count, + "generated_image_count": generated_image_count, + "generated_video_count": generated_video_count, + "generated_total_count": generated_image_count + generated_video_count, + } + snapshot = { + "schema_version": 1, + "stage": stage or "unknown", + "gen_type": gen_type, + "requested_output_count": requested_output_count, + "generated_image_count": generated_image_count, + "generated_video_count": generated_video_count, + "generated_total_count": generated_image_count + generated_video_count, + "output_items": output_items, + "duration_seconds": output_duration or None, + "resolution": resolution, + "aspect_ratio": aspect_ratio, + "width": width or None, + "height": height or None, + "dimension_source": dimension_source, + "fps": fps or None, + "generate_audio": generate_audio, + "inference_mode": inference_mode, + } + usage = { + "requested_output_count": requested_output_count, + "generated_image_count": generated_image_count, + "generated_video_count": generated_video_count, + "successful_output_count": generated_image_count if gen_type == "image" else generated_video_count, + "output_items": output_items, + "output_width": width, + "output_height": height, + "dimension_source": dimension_source, + "output_video_duration_seconds": output_duration, + "resolution": str(resolution or "").lower(), + "aspect_ratio": str(aspect_ratio or ""), + "fps": fps, + "generate_audio": generate_audio, + "inference_mode": inference_mode, + "usage_stage": stage or "unknown", + } + return snapshot, counts, usage diff --git a/video-gen-api/app/services/model_pricing/calculator.py b/video-gen-api/app/services/model_pricing/calculator.py new file mode 100644 index 00000000..01d1be8e --- /dev/null +++ b/video-gen-api/app/services/model_pricing/calculator.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP +from typing import Any, Mapping + +from app.enums.model_pricing import ( + ModelPricingBillingMode, + ModelPricingCalculatorVersion, + PricingBillBy, +) +from app.services.model_pricing.usage_normalizer import safe_bool, safe_int + + +MILLION = Decimal("1000000") +MONEY_QUANT = Decimal("0.00000001") + + +class PricingCalculationError(ValueError): + pass + + +@dataclass(slots=True) +class PricingCalculationResult: + amount: Decimal + currency: str + is_estimated: bool + selected_rate: Decimal | None + breakdown: dict[str, Any] + usage_source: str + + +def to_decimal(value: Any, default: str = "0") -> Decimal: + try: + if value in (None, ""): + return Decimal(default) + return Decimal(str(value)) + except Exception: + return Decimal(default) + + +def money(value: Decimal) -> Decimal: + return value.quantize(MONEY_QUANT, rounding=ROUND_HALF_UP) + + +def _select_text_tier(rule_json: Mapping[str, Any], context_tokens: int) -> Mapping[str, Any]: + for tier in rule_json.get("tiers") or []: + maximum = tier.get("max_context_tokens") + if maximum is None or context_tokens <= safe_int(maximum): + return tier + raise PricingCalculationError(f"没有匹配到文本 Token 档位: context_tokens={context_tokens}") + + +def _calculate_text(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: + input_tokens = max(0, safe_int(usage.get("input_tokens"))) + output_tokens = max(0, safe_int(usage.get("output_tokens"))) + context_tokens = max(0, safe_int(usage.get("context_tokens"), input_tokens)) + cached_input = max(0, min(input_tokens, safe_int(usage.get("cached_input_tokens")))) + audio_input = max(0, min(input_tokens, safe_int(usage.get("audio_input_tokens")))) + cached_audio = max(0, min(audio_input, safe_int(usage.get("cached_audio_input_tokens")))) + + tier = _select_text_tier(rule_json, context_tokens) + cached_text_input = max(0, cached_input - cached_audio) + normal_audio_input = max(0, audio_input - cached_audio) + # cached_input_tokens may include cached audio tokens. Add cached_audio back once + # so the four buckets always sum exactly to input_tokens. + normal_text_input = max(0, input_tokens - audio_input - cached_text_input) + + input_rate = to_decimal(tier.get("input_rate")) + output_rate = to_decimal(tier.get("output_rate")) + cached_rate = to_decimal(tier.get("cached_input_rate"), str(input_rate)) + audio_rate = to_decimal(tier.get("audio_input_rate"), str(input_rate)) + cached_audio_rate = to_decimal(tier.get("cached_audio_input_rate"), str(cached_rate)) + + normal_input_cost = to_decimal(normal_text_input) * input_rate / MILLION + cached_input_cost = to_decimal(cached_text_input) * cached_rate / MILLION + audio_input_cost = to_decimal(normal_audio_input) * audio_rate / MILLION + cached_audio_cost = to_decimal(cached_audio) * cached_audio_rate / MILLION + output_cost = to_decimal(output_tokens) * output_rate / MILLION + + cache_storage_tokens = max(0, safe_int(usage.get("cache_storage_tokens"))) + cache_storage_hours = max(Decimal("0"), to_decimal(usage.get("cache_storage_duration_hours"))) + storage_rate = to_decimal(rule_json.get("cache_storage_rate_per_million_token_hour")) + cache_storage_cost = to_decimal(cache_storage_tokens) * cache_storage_hours * storage_rate / MILLION + + total = money( + normal_input_cost + + cached_input_cost + + audio_input_cost + + cached_audio_cost + + output_cost + + cache_storage_cost + ) + return PricingCalculationResult( + amount=total, + currency=currency, + is_estimated=False, + selected_rate=None, + usage_source=str(usage.get("usage_source") or "provider"), + breakdown={ + "formula": "token_items * corresponding_rate / 1e6", + "context_tokens": context_tokens, + "selected_tier": dict(tier), + "normal_text_input_tokens": normal_text_input, + "cached_text_input_tokens": cached_text_input, + "normal_audio_input_tokens": normal_audio_input, + "cached_audio_input_tokens": cached_audio, + "output_tokens": output_tokens, + "cache_storage_tokens": cache_storage_tokens, + "cache_storage_duration_hours": str(cache_storage_hours), + "normal_input_cost": str(money(normal_input_cost)), + "cached_input_cost": str(money(cached_input_cost)), + "audio_input_cost": str(money(audio_input_cost)), + "cached_audio_input_cost": str(money(cached_audio_cost)), + "output_cost": str(money(output_cost)), + "cache_storage_cost": str(money(cache_storage_cost)), + "total_cost": str(total), + }, + ) + + +def _resolve_billable_output_count(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, str, bool]: + bill_by = str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value) + if bill_by == PricingBillBy.REQUESTED_OUTPUT_COUNT.value: + return max(0, safe_int(usage.get("requested_output_count"))), bill_by, True + if bill_by == PricingBillBy.PROVIDER_BILLED_COUNT.value: + count = max(0, safe_int(usage.get("provider_billed_count"))) + return count, bill_by, count <= 0 + if bill_by != PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value: + raise PricingCalculationError(f"不支持的图片计费数量来源: {bill_by}") + return max(0, safe_int(usage.get("successful_output_count"), safe_int(usage.get("generated_image_count")))), bill_by, False + + +def _calculate_image_per_output(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: + count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage) + if count <= 0: + raise PricingCalculationError("图片计价缺少有效输出数量") + rate = to_decimal(rule_json.get("output_rate")) + total = money(to_decimal(count) * rate) + return PricingCalculationResult( + amount=total, + currency=currency, + is_estimated=count_estimated or safe_bool(usage.get("output_count_is_estimated")), + selected_rate=rate, + usage_source=str(usage.get("usage_source") or "provider_response"), + breakdown={ + "formula": "billable_output_count * output_rate", + "bill_by": bill_by, + "billable_output_count": count, + "output_rate": str(rate), + "total_cost": str(total), + }, + ) + + +def _select_image_tier(output_tiers: list[Mapping[str, Any]], pixels: int) -> Mapping[str, Any]: + for tier in output_tiers: + maximum = tier.get("max_pixels") + if maximum is None or pixels <= safe_int(maximum): + return tier + raise PricingCalculationError(f"没有匹配到图片输出像素档位: pixels={pixels}") + + +def _calculate_image_tiered(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: + input_count = max(0, safe_int(usage.get("provider_input_image_count"), safe_int(usage.get("input_image_count")))) + free_count = max(0, safe_int(rule_json.get("free_input_images"))) + billable_input_count = max(0, input_count - free_count) + input_rate = to_decimal(rule_json.get("input_image_rate")) + input_cost = to_decimal(billable_input_count) * input_rate + + output_items = usage.get("output_items") or [] + if not isinstance(output_items, list): + output_items = [] + count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage) + if count <= 0: + raise PricingCalculationError("图片计价缺少有效输出数量") + + output_tiers = list(rule_json.get("output_tiers") or []) + output_cost = Decimal("0") + item_breakdown: list[dict[str, Any]] = [] + pixels_estimated = False + + if output_items: + priced_count = 0 + for index, item in enumerate(output_items[:count]): + if not isinstance(item, Mapping): + continue + pixels = max(0, safe_int(item.get("pixels"))) + if pixels <= 0: + width = max(0, safe_int(item.get("width"))) + height = max(0, safe_int(item.get("height"))) + pixels = width * height + if pixels <= 0: + raise PricingCalculationError(f"第 {index + 1} 张输出图片缺少明确像素") + tier = _select_image_tier(output_tiers, pixels) + rate = to_decimal(tier.get("rate")) + output_cost += rate + priced_count += 1 + item_breakdown.append({"index": index, "pixels": pixels, "tier": dict(tier), "rate": str(rate)}) + + # provider_billed_count/requested_output_count may be greater than the returned + # output item array. Only use an explicit fallback size; never silently under-bill. + remaining = count - priced_count + if remaining > 0: + fallback_pixels = max(0, safe_int(usage.get("output_pixels"))) + if fallback_pixels <= 0: + fallback_width = max(0, safe_int(usage.get("output_width"))) + fallback_height = max(0, safe_int(usage.get("output_height"))) + fallback_pixels = fallback_width * fallback_height + if fallback_pixels <= 0: + raise PricingCalculationError(f"仍有 {remaining} 张计费输出缺少明确像素") + tier = _select_image_tier(output_tiers, fallback_pixels) + rate = to_decimal(tier.get("rate")) + output_cost += to_decimal(remaining) * rate + pixels_estimated = True + item_breakdown.append( + { + "count": remaining, + "pixels": fallback_pixels, + "tier": dict(tier), + "rate": str(rate), + "size_source": "explicit_fallback", + } + ) + else: + pixels = max(0, safe_int(usage.get("output_pixels"))) + if pixels <= 0: + width = max(0, safe_int(usage.get("output_width"))) + height = max(0, safe_int(usage.get("output_height"))) + pixels = width * height + if pixels <= 0: + raise PricingCalculationError("图片输出缺少明确像素") + tier = _select_image_tier(output_tiers, pixels) + rate = to_decimal(tier.get("rate")) + output_cost = to_decimal(count) * rate + pixels_estimated = True + item_breakdown = [{"count": count, "pixels": pixels, "tier": dict(tier), "rate": str(rate)}] + + total = money(input_cost + output_cost) + return PricingCalculationResult( + amount=total, + currency=currency, + is_estimated=count_estimated or pixels_estimated or safe_bool(usage.get("output_pixels_is_estimated")), + selected_rate=None, + usage_source=str(usage.get("usage_source") or "provider_response"), + breakdown={ + "formula": "billable_input_count*input_image_rate + sum(output_item_rate)", + "bill_by": bill_by, + "provider_input_image_count": input_count, + "free_input_images": free_count, + "billable_input_image_count": billable_input_count, + "input_image_rate": str(input_rate), + "input_cost": str(money(input_cost)), + "billable_output_count": count, + "output_items": item_breakdown, + "output_cost": str(money(output_cost)), + "total_cost": str(total), + }, + ) + + +def _normalize_resolution(value: Any) -> str: + resolution = str(value or "").lower().strip().replace(" ", "") + return {"2160p": "4k", "uhd": "4k"}.get(resolution, resolution) + + +def _normalize_ratio(value: Any) -> str: + return str(value or "").strip().replace(":", ":") + + +def _resolve_video_dimensions(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, int, str]: + width = max(0, safe_int(usage.get("output_width"))) + height = max(0, safe_int(usage.get("output_height"))) + if width > 0 and height > 0: + return width, height, str(usage.get("dimension_source") or "provider_response") + + resolution = _normalize_resolution(usage.get("resolution")) + ratio = _normalize_ratio(usage.get("aspect_ratio")) + dimension_map = rule_json.get("dimension_map") or {} + resolution_map = dimension_map.get(resolution) if isinstance(dimension_map, Mapping) else None + value = resolution_map.get(ratio) if isinstance(resolution_map, Mapping) else None + if isinstance(value, Mapping): + width = max(0, safe_int(value.get("width"))) + height = max(0, safe_int(value.get("height"))) + elif isinstance(value, (list, tuple)) and len(value) >= 2: + width, height = max(0, safe_int(value[0])), max(0, safe_int(value[1])) + if width <= 0 or height <= 0: + raise PricingCalculationError(f"视频缺少明确尺寸,且价格规则未配置 dimension_map: resolution={resolution}, ratio={ratio}") + return width, height, "pricing_rule_map" + + +def _rate_specificity(row: Mapping[str, Any]) -> tuple[int, int, int]: + resolutions = {_normalize_resolution(v) for v in (row.get("resolutions") or [])} + modes = {str(v).lower() for v in (row.get("inference_modes") or [])} + constrained = int(bool(resolutions)) + int(row.get("has_input_video") is not None) + int( + row.get("generate_audio") is not None + ) + int(bool(modes)) + # More constrained dimensions win; within a dimension, a smaller allowed set is + # more specific. Empty sets represent wildcard and therefore score lowest. + return constrained, -(len(resolutions) if resolutions else 10_000), -(len(modes) if modes else 10_000) + + +def _match_video_rate(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> Mapping[str, Any]: + resolution = _normalize_resolution(usage.get("resolution")) + has_input_video = safe_bool(usage.get("has_input_video")) + generate_audio = safe_bool(usage.get("generate_audio")) + inference_mode = str(usage.get("inference_mode") or "online").lower().strip() + matched: list[Mapping[str, Any]] = [] + for row in rule_json.get("rates") or []: + resolutions = [_normalize_resolution(v) for v in (row.get("resolutions") or [])] + if resolutions and resolution not in resolutions: + continue + if row.get("has_input_video") is not None and safe_bool(row.get("has_input_video")) != has_input_video: + continue + if row.get("generate_audio") is not None and safe_bool(row.get("generate_audio")) != generate_audio: + continue + modes = [str(v).lower() for v in (row.get("inference_modes") or [])] + if modes and inference_mode not in modes: + continue + matched.append(row) + if not matched: + raise PricingCalculationError( + "没有匹配到视频价格档位: " + f"resolution={resolution}, has_input_video={has_input_video}, " + f"generate_audio={generate_audio}, inference_mode={inference_mode}" + ) + matched.sort(key=_rate_specificity, reverse=True) + if len(matched) > 1 and _rate_specificity(matched[0]) == _rate_specificity(matched[1]): + raise PricingCalculationError("视频价格档位存在同等优先级重叠,请修正规则") + return matched[0] + + +def calculate_video_formula_tokens(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, dict[str, Any]]: + input_seconds = max(Decimal("0"), to_decimal(usage.get("input_video_duration_seconds"))) + output_seconds = max(Decimal("0"), to_decimal(usage.get("output_video_duration_seconds"))) + fps = max(Decimal("0"), to_decimal(usage.get("fps"))) + if fps <= 0: + fps = max(Decimal("0"), to_decimal(rule_json.get("default_fps"))) + width, height, dimension_source = _resolve_video_dimensions(rule_json, usage) + if output_seconds <= 0 or fps <= 0: + raise PricingCalculationError("视频公式估算缺少输出时长或 FPS") + if safe_bool(usage.get("has_input_video")) and input_seconds <= 0: + raise PricingCalculationError("视频包含输入视频,但缺少输入视频时长,禁止估算") + tokens = (input_seconds + output_seconds) * Decimal(width) * Decimal(height) * fps / Decimal("1024") + rounded = max(0, int(tokens.quantize(Decimal("1"), rounding=ROUND_HALF_UP))) + return rounded, { + "input_video_duration_seconds": str(input_seconds), + "output_video_duration_seconds": str(output_seconds), + "output_width": width, + "output_height": height, + "fps": str(fps), + "dimension_source": dimension_source, + } + + +def _calculate_video(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: + actual_tokens = max(0, safe_int(usage.get("total_tokens"))) + formula_detail: dict[str, Any] = {} + if actual_tokens > 0: + total_tokens = actual_tokens + use_formula = False + else: + total_tokens, formula_detail = calculate_video_formula_tokens(rule_json, usage) + use_formula = True + rate_row = _match_video_rate(rule_json, usage) + rate = to_decimal(rate_row.get("rate")) + total = money(to_decimal(total_tokens) * rate / MILLION) + return PricingCalculationResult( + amount=total, + currency=currency, + is_estimated=use_formula, + selected_rate=rate, + usage_source="request_formula" if use_formula else str(usage.get("usage_source") or "provider"), + breakdown={ + "formula": "billable_total_tokens * rate / 1e6", + "token_source": "request_formula" if use_formula else "provider", + "provider_total_tokens": actual_tokens, + "billable_total_tokens": total_tokens, + "formula_parameters": formula_detail or None, + "selected_rate_rule": dict(rate_row), + "rate": str(rate), + "total_cost": str(total), + }, + ) + + +def _expected_calculator(billing_mode: str) -> str: + mapping = { + ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value, + ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value, + ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value, + ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + } + value = mapping.get(billing_mode) + if not value: + raise PricingCalculationError(f"不支持的计价模式: {billing_mode}") + return value + + +def _constraint_set(values: Any, *, normalize) -> set[str] | None: + normalized = {normalize(value) for value in (values or []) if str(value or "").strip()} + return normalized or None + + +def _constraints_overlap(left: Mapping[str, Any], right: Mapping[str, Any]) -> bool: + left_res = _constraint_set(left.get("resolutions"), normalize=_normalize_resolution) + right_res = _constraint_set(right.get("resolutions"), normalize=_normalize_resolution) + if left_res is not None and right_res is not None and left_res.isdisjoint(right_res): + return False + + for key in ("has_input_video", "generate_audio"): + lv, rv = left.get(key), right.get(key) + if lv is not None and rv is not None and safe_bool(lv) != safe_bool(rv): + return False + + left_modes = _constraint_set(left.get("inference_modes"), normalize=lambda value: str(value).lower()) + right_modes = _constraint_set(right.get("inference_modes"), normalize=lambda value: str(value).lower()) + if left_modes is not None and right_modes is not None and left_modes.isdisjoint(right_modes): + return False + return True + + +def _constraint_subset(child: Mapping[str, Any], parent: Mapping[str, Any]) -> bool: + child_res = _constraint_set(child.get("resolutions"), normalize=_normalize_resolution) + parent_res = _constraint_set(parent.get("resolutions"), normalize=_normalize_resolution) + if parent_res is not None and (child_res is None or not child_res.issubset(parent_res)): + return False + + for key in ("has_input_video", "generate_audio"): + child_value, parent_value = child.get(key), parent.get(key) + if parent_value is not None and (child_value is None or safe_bool(child_value) != safe_bool(parent_value)): + return False + + child_modes = _constraint_set(child.get("inference_modes"), normalize=lambda value: str(value).lower()) + parent_modes = _constraint_set(parent.get("inference_modes"), normalize=lambda value: str(value).lower()) + if parent_modes is not None and (child_modes is None or not child_modes.issubset(parent_modes)): + return False + return True + + +def _validate_video_rate_overlaps(rates: list[Mapping[str, Any]]) -> None: + for left_index, left in enumerate(rates): + for right_index in range(left_index + 1, len(rates)): + right = rates[right_index] + if not _constraints_overlap(left, right): + continue + left_subset_right = _constraint_subset(left, right) + right_subset_left = _constraint_subset(right, left) + if left_subset_right and right_subset_left: + raise PricingCalculationError("视频价格档位存在重复条件") + if not left_subset_right and not right_subset_left: + raise PricingCalculationError("视频价格档位存在交叉重叠,无法确定唯一价格") + # The more specific row must be before its fallback row, matching the UI + # and keeping exported rule JSON human-readable and deterministic. + if right_subset_left: + raise PricingCalculationError("视频价格档位顺序错误:具体条件必须放在通用兜底条件之前") + + +def validate_pricing_rule(*, billing_mode: str, calculator_version: str, rule_json: Mapping[str, Any]) -> None: + if calculator_version != _expected_calculator(billing_mode): + raise PricingCalculationError(f"计价模式与计算器版本不匹配: {billing_mode}/{calculator_version}") + + if billing_mode == ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: + tiers = list(rule_json.get("tiers") or []) + if not tiers: + raise PricingCalculationError("文本计价至少需要一个 Token 档位") + previous_max = 0 + for index, tier in enumerate(tiers, start=1): + maximum = tier.get("max_context_tokens") + if maximum is None and index != len(tiers): + raise PricingCalculationError("无上限 Token 档位只能放在最后") + if maximum is not None: + maximum_int = safe_int(maximum) + if maximum_int <= previous_max: + raise PricingCalculationError("Token 档位上限必须严格递增") + previous_max = maximum_int + for key in ("input_rate", "output_rate"): + if to_decimal(tier.get(key), "-1") < 0: + raise PricingCalculationError(f"{key} 不能为空且不能小于 0") + return + + if billing_mode == ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: + if to_decimal(rule_json.get("output_rate"), "-1") < 0: + raise PricingCalculationError("图片输出单价不能为空且不能小于 0") + if str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value) not in {v.value for v in PricingBillBy}: + raise PricingCalculationError("bill_by 不受支持") + return + + if billing_mode == ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: + if safe_int(rule_json.get("free_input_images")) < 0: + raise PricingCalculationError("免费输入图片数不能小于 0") + if to_decimal(rule_json.get("input_image_rate"), "-1") < 0: + raise PricingCalculationError("输入图片单价不能为空且不能小于 0") + tiers = list(rule_json.get("output_tiers") or []) + if not tiers: + raise PricingCalculationError("图片输出至少需要一个像素档位") + previous_max = 0 + for index, tier in enumerate(tiers, start=1): + maximum = tier.get("max_pixels") + if maximum is None and index != len(tiers): + raise PricingCalculationError("无上限像素档位只能放在最后") + if maximum is not None: + maximum_int = safe_int(maximum) + if maximum_int <= previous_max: + raise PricingCalculationError("图片像素档位上限必须严格递增") + previous_max = maximum_int + if to_decimal(tier.get("rate"), "-1") < 0: + raise PricingCalculationError("图片输出单价不能为空且不能小于 0") + return + + if billing_mode == ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: + rates = list(rule_json.get("rates") or []) + if not rates: + raise PricingCalculationError("视频计价至少需要一个价格档位") + signatures: set[tuple[Any, ...]] = set() + for row in rates: + if to_decimal(row.get("rate"), "-1") < 0: + raise PricingCalculationError("视频 Token 单价不能为空且不能小于 0") + signature = ( + tuple(sorted(_normalize_resolution(v) for v in (row.get("resolutions") or []))), + row.get("has_input_video"), + row.get("generate_audio"), + tuple(sorted(str(v).lower() for v in (row.get("inference_modes") or []))), + ) + if signature in signatures: + raise PricingCalculationError("视频价格档位存在重复条件") + signatures.add(signature) + _validate_video_rate_overlaps(rates) + dimension_map = rule_json.get("dimension_map") or {} + if dimension_map and not isinstance(dimension_map, Mapping): + raise PricingCalculationError("dimension_map 必须是对象") + return + + raise PricingCalculationError(f"不支持的计价模式: {billing_mode}") + + +def calculate_pricing( + *, + billing_mode: str, + calculator_version: str, + rule_json: Mapping[str, Any], + usage: Mapping[str, Any], + currency: str = "CNY", +) -> PricingCalculationResult: + validate_pricing_rule( + billing_mode=billing_mode, + calculator_version=calculator_version, + rule_json=rule_json, + ) + if calculator_version == ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value: + return _calculate_text(rule_json, usage, currency) + if calculator_version == ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value: + return _calculate_image_per_output(rule_json, usage, currency) + if calculator_version == ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value: + return _calculate_image_tiered(rule_json, usage, currency) + if calculator_version == ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value: + return _calculate_video(rule_json, usage, currency) + raise PricingCalculationError(f"不支持的计算器版本: {calculator_version}") diff --git a/video-gen-api/app/services/model_pricing/rule_service.py b/video-gen-api/app/services/model_pricing/rule_service.py new file mode 100644 index 00000000..6904f182 --- /dev/null +++ b/video-gen-api/app/services/model_pricing/rule_service.py @@ -0,0 +1,517 @@ +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from datetime import datetime, timezone +from typing import Any, Mapping + +from sqlalchemy import func, or_, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.model_pricing import ModelPricingRuleStatus +from app.models.credit_record import CreditRecord +from app.models.model_pricing_rule import ModelPricingRule +from app.services.model_pricing.calculator import PricingCalculationError, validate_pricing_rule +from app.utils.id_gen import generate_id + + +PROVIDER_ALIASES = { + "ark": "volcengine", + "volc": "volcengine", + "volc_engine": "volcengine", + "volcano": "volcengine", + "volcengine": "volcengine", +} + + +class PricingRuleError(ValueError): + pass + + +def _json_default(value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + return str(value) + + +def canonical_json_hash(value: Mapping[str, Any]) -> str: + raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def build_rule_content_hash( + *, + model_category: str, + billing_mode: str, + calculator_version: str, + currency: str, + rule_schema_version: int, + rule_json: Mapping[str, Any], +) -> str: + """规则正文哈希包含所有会改变计算结果的字段,不只哈希 rule_json。""" + return canonical_json_hash( + { + "model_category": model_category, + "billing_mode": billing_mode, + "calculator_version": calculator_version, + "currency": str(currency or "CNY").upper(), + "rule_schema_version": int(rule_schema_version or 1), + "rule_json": normalize_rule_json(rule_json), + } + ) + + +def normalize_rule_json(value: Mapping[str, Any] | None) -> dict[str, Any]: + """返回全新的普通 dict,所有 ORM 更新必须整体赋值,禁止嵌套原地修改。""" + return deepcopy(dict(value or {})) + + +def normalize_provider(provider: str | None, model_name: str | None = None) -> str: + value = str(provider or "").strip().lower() + normalized = PROVIDER_ALIASES.get(value, value) + if normalized in {"sdk", "openai_compatible"} and str(model_name or "").strip().lower().startswith("doubao-"): + return "volcengine" + return normalized + + +def ensure_aware(value: datetime | None) -> datetime: + value = value or datetime.now(timezone.utc) + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +def _validate_category_mode(model_category: str, billing_mode: str) -> None: + expected = { + "text_token_tiered": "text", + "image_per_output": "image", + "image_input_output_tiered": "image", + "video_token_rate": "video", + }.get(billing_mode) + if expected is None or model_category != expected: + raise PricingRuleError("模型类型与计价模式不匹配") + + +def _validate_rule_payload( + *, + model_category: str, + billing_mode: str, + calculator_version: str, + rule_json: Mapping[str, Any], +) -> None: + _validate_category_mode(model_category, billing_mode) + try: + validate_pricing_rule( + billing_mode=billing_mode, + calculator_version=calculator_version, + rule_json=rule_json, + ) + except PricingCalculationError as exc: + raise PricingRuleError(str(exc)) from exc + + +def rule_to_dict(rule: ModelPricingRule) -> dict[str, Any]: + return { + "id": rule.id, + "provider": rule.provider, + "model_name": rule.model_name, + "model_category": rule.model_category, + "billing_mode": rule.billing_mode, + "calculator_version": rule.calculator_version, + "version_code": rule.version_code, + "effective_from": rule.effective_from, + "effective_to": rule.effective_to, + "publish_status": rule.publish_status, + "currency": rule.currency, + "rule_schema_version": rule.rule_schema_version, + "rule_json": deepcopy(rule.rule_json or {}), + "rule_content_hash": rule.rule_content_hash, + "source_url": rule.source_url, + "source_updated_at": rule.source_updated_at, + "remark": rule.remark, + "created_by": rule.created_by, + "updated_by": rule.updated_by, + "created_at": rule.created_at, + "updated_at": rule.updated_at, + } + + +def _rule_snapshot_query(rule_id: str): + return ( + select( + ModelPricingRule.id, + ModelPricingRule.provider, + ModelPricingRule.model_name, + ModelPricingRule.model_category, + ModelPricingRule.billing_mode, + ModelPricingRule.calculator_version, + ModelPricingRule.version_code, + ModelPricingRule.effective_from, + ModelPricingRule.effective_to, + ModelPricingRule.publish_status, + ModelPricingRule.currency, + ModelPricingRule.rule_schema_version, + ModelPricingRule.rule_json, + ModelPricingRule.rule_content_hash, + ModelPricingRule.source_url, + ModelPricingRule.source_updated_at, + ModelPricingRule.remark, + ModelPricingRule.created_by, + ModelPricingRule.updated_by, + ModelPricingRule.created_at, + ModelPricingRule.updated_at, + ) + .where(ModelPricingRule.id == rule_id) + .limit(1) + ) + + +def _snapshot_from_mapping(row: Mapping[str, Any]) -> dict[str, Any]: + snapshot = dict(row) + snapshot["rule_json"] = deepcopy(snapshot.get("rule_json") or {}) + return snapshot + + +async def get_rule_snapshot(db: AsyncSession, rule_id: str) -> dict[str, Any]: + """显式查询并返回普通字典,避免写入后访问过期 ORM 字段触发隐式 IO。""" + row = (await db.execute(_rule_snapshot_query(rule_id))).mappings().one_or_none() + if row is None: + raise PricingRuleError("模型计价规则不存在") + return _snapshot_from_mapping(row) + + +async def _lock_model_rule_namespace(db: AsyncSession, provider: str, model_name: str) -> None: + bind = db.get_bind() + if bind is not None and bind.dialect.name == "postgresql": + lock_key = f"model_pricing:{provider}:{model_name}" + await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": lock_key}) + + +async def _assert_unique_version( + db: AsyncSession, + *, + provider: str, + model_name: str, + version_code: str, + exclude_id: str | None = None, +) -> None: + query = select(ModelPricingRule.id).where( + ModelPricingRule.provider == provider, + ModelPricingRule.model_name == model_name, + ModelPricingRule.version_code == version_code, + ) + if exclude_id: + query = query.where(ModelPricingRule.id != exclude_id) + if (await db.execute(query.limit(1))).scalar_one_or_none(): + raise PricingRuleError("该供应商、模型和价格版本号已存在") + + +async def _assert_no_overlap( + db: AsyncSession, + *, + provider: str, + model_name: str, + effective_from: datetime, + effective_to: datetime | None, + exclude_id: str | None = None, +) -> None: + query = ( + select(ModelPricingRule.id) + .where(ModelPricingRule.provider == provider) + .where(ModelPricingRule.model_name == model_name) + .where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value) + .where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > effective_from)) + ) + if effective_to is not None: + query = query.where(ModelPricingRule.effective_from < effective_to) + if exclude_id: + query = query.where(ModelPricingRule.id != exclude_id) + if (await db.execute(query.limit(1))).scalar_one_or_none(): + raise PricingRuleError("该模型已存在生效时间重叠的已发布价格版本") + + +async def resolve_published_rule( + db: AsyncSession, + *, + provider: str | None, + model_name: str | None, + reference_at: datetime | None = None, +) -> ModelPricingRule | None: + model_name = str(model_name or "").strip() + provider = normalize_provider(provider, model_name) + if not provider or not model_name: + return None + at = ensure_aware(reference_at) + result = await db.execute( + select(ModelPricingRule) + .where(ModelPricingRule.provider == provider) + .where(ModelPricingRule.model_name == model_name) + .where(ModelPricingRule.publish_status.in_([ModelPricingRuleStatus.PUBLISHED.value, ModelPricingRuleStatus.DISABLED.value])) + .where(ModelPricingRule.effective_from <= at) + .where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > at)) + .order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def list_rules( + db: AsyncSession, + *, + page: int = 1, + page_size: int = 50, + provider: str | None = None, + model_name: str | None = None, + model_category: str | None = None, + publish_status: str | None = None, +) -> dict[str, Any]: + filters = [] + if provider: + filters.append(ModelPricingRule.provider == normalize_provider(provider)) + if model_name: + filters.append(ModelPricingRule.model_name.ilike(f"%{model_name.strip()}%")) + if model_category: + filters.append(ModelPricingRule.model_category == model_category) + if publish_status: + filters.append(ModelPricingRule.publish_status == publish_status) + total = (await db.execute(select(func.count(ModelPricingRule.id)).where(*filters))).scalar_one() + rows = ( + await db.execute( + select(ModelPricingRule) + .where(*filters) + .order_by(ModelPricingRule.model_name, ModelPricingRule.effective_from.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + rule_ids = [row.id for row in rows] + referenced: dict[str, int] = {} + if rule_ids: + ref_rows = ( + await db.execute( + select(CreditRecord.pricing_rule_id, func.count(CreditRecord.id)) + .where(CreditRecord.pricing_rule_id.in_(rule_ids)) + .group_by(CreditRecord.pricing_rule_id) + ) + ).all() + referenced = {str(rule_id): int(count) for rule_id, count in ref_rows if rule_id} + items = [] + for row in rows: + item = rule_to_dict(row) + item["referenced_count"] = referenced.get(row.id, 0) + items.append(item) + return {"items": items, "total": int(total or 0)} + + +async def get_rule(db: AsyncSession, rule_id: str, *, for_update: bool = False) -> ModelPricingRule: + query = select(ModelPricingRule).where(ModelPricingRule.id == rule_id).limit(1) + if for_update: + query = query.with_for_update() + rule = (await db.execute(query)).scalar_one_or_none() + if not rule: + raise PricingRuleError("模型计价规则不存在") + return rule + + +async def create_rule(db: AsyncSession, *, payload: dict[str, Any], operator_id: str | None) -> dict[str, Any]: + effective_from = ensure_aware(payload["effective_from"]) + effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None + if effective_to and effective_to <= effective_from: + raise PricingRuleError("失效时间必须晚于生效时间") + model_name = str(payload.get("model_name") or "").strip() + provider = normalize_provider(payload.get("provider"), model_name) + version_code = str(payload.get("version_code") or "").strip() + calculator_version = str(payload.get("calculator_version") or "").strip() + if not provider or not model_name or not version_code or not calculator_version: + raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空") + rule_json = normalize_rule_json(payload.get("rule_json")) + _validate_rule_payload( + model_category=payload["model_category"], + billing_mode=payload["billing_mode"], + calculator_version=calculator_version, + rule_json=rule_json, + ) + await _lock_model_rule_namespace(db, provider, model_name) + await _assert_unique_version(db, provider=provider, model_name=model_name, version_code=version_code) + rule_id = generate_id() + rule = ModelPricingRule( + id=rule_id, + provider=provider, + model_name=model_name, + model_category=payload["model_category"], + billing_mode=payload["billing_mode"], + calculator_version=calculator_version, + version_code=version_code, + effective_from=effective_from, + effective_to=effective_to, + publish_status=ModelPricingRuleStatus.DRAFT.value, + currency=str(payload.get("currency") or "CNY").upper(), + rule_schema_version=int(payload.get("rule_schema_version") or 1), + rule_json=rule_json, + rule_content_hash=build_rule_content_hash( + model_category=payload["model_category"], + billing_mode=payload["billing_mode"], + calculator_version=calculator_version, + currency=str(payload.get("currency") or "CNY").upper(), + rule_schema_version=int(payload.get("rule_schema_version") or 1), + rule_json=rule_json, + ), + source_url=payload.get("source_url"), + source_updated_at=payload.get("source_updated_at"), + remark=payload.get("remark"), + created_by=operator_id, + updated_by=operator_id, + ) + db.add(rule) + await db.flush() + return await get_rule_snapshot(db, rule_id) + + +async def update_draft_rule( + db: AsyncSession, + *, + rule_id: str, + payload: dict[str, Any], + operator_id: str | None, +) -> dict[str, Any]: + rule = await get_rule(db, rule_id, for_update=True) + if rule.publish_status != ModelPricingRuleStatus.DRAFT.value: + raise PricingRuleError("已发布或已停用的价格版本不可修改,请克隆为新版本") + + provider = normalize_provider(payload.get("provider", rule.provider), payload.get("model_name", rule.model_name)) + model_name = str(payload.get("model_name", rule.model_name) or "").strip() + version_code = str(payload.get("version_code", rule.version_code) or "").strip() + model_category = str(payload.get("model_category", rule.model_category)) + billing_mode = str(payload.get("billing_mode", rule.billing_mode)) + calculator_version = str(payload.get("calculator_version", rule.calculator_version)) + rule_json = normalize_rule_json(payload["rule_json"] if "rule_json" in payload else rule.rule_json) + effective_from = ensure_aware(payload.get("effective_from", rule.effective_from)) + effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None if "effective_to" in payload else rule.effective_to + + if effective_to and effective_to <= effective_from: + raise PricingRuleError("失效时间必须晚于生效时间") + if not provider or not model_name or not version_code or not calculator_version: + raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空") + _validate_rule_payload( + model_category=model_category, + billing_mode=billing_mode, + calculator_version=calculator_version, + rule_json=rule_json, + ) + await _lock_model_rule_namespace(db, provider, model_name) + await _assert_unique_version( + db, + provider=provider, + model_name=model_name, + version_code=version_code, + exclude_id=rule.id, + ) + + rule.provider = provider + rule.model_name = model_name + rule.model_category = model_category + rule.billing_mode = billing_mode + rule.calculator_version = calculator_version + rule.version_code = version_code + rule.effective_from = effective_from + rule.effective_to = effective_to + rule.currency = str(payload.get("currency", rule.currency) or "CNY").upper() + rule.rule_schema_version = int(payload.get("rule_schema_version", rule.rule_schema_version) or 1) + rule.rule_json = rule_json + rule.rule_content_hash = build_rule_content_hash( + model_category=rule.model_category, + billing_mode=rule.billing_mode, + calculator_version=rule.calculator_version, + currency=rule.currency, + rule_schema_version=rule.rule_schema_version, + rule_json=rule.rule_json, + ) + for key in ("source_url", "source_updated_at", "remark"): + if key in payload: + setattr(rule, key, payload[key]) + rule.updated_by = operator_id + await db.flush() + return await get_rule_snapshot(db, rule_id) + + +async def publish_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]: + rule = await get_rule(db, rule_id, for_update=True) + publish_status = rule.publish_status + if publish_status == ModelPricingRuleStatus.PUBLISHED.value: + return await get_rule_snapshot(db, rule_id) + if publish_status != ModelPricingRuleStatus.DRAFT.value: + raise PricingRuleError("只有草稿价格版本可以发布") + + provider = rule.provider + model_name = rule.model_name + model_category = rule.model_category + billing_mode = rule.billing_mode + calculator_version = rule.calculator_version + effective_from = rule.effective_from + effective_to = rule.effective_to + currency = rule.currency + rule_schema_version = rule.rule_schema_version + rule_json = normalize_rule_json(rule.rule_json) + + await _lock_model_rule_namespace(db, provider, model_name) + _validate_rule_payload( + model_category=model_category, + billing_mode=billing_mode, + calculator_version=calculator_version, + rule_json=rule_json, + ) + + previous = ( + await db.execute( + select(ModelPricingRule) + .where(ModelPricingRule.provider == provider) + .where(ModelPricingRule.model_name == model_name) + .where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value) + .where(ModelPricingRule.effective_from < effective_from) + .where(ModelPricingRule.effective_to.is_(None)) + .order_by(ModelPricingRule.effective_from.desc()) + .limit(1) + .with_for_update() + ) + ).scalar_one_or_none() + if previous: + previous.effective_to = effective_from + previous.updated_by = operator_id + await db.flush() + + await _assert_no_overlap( + db, + provider=provider, + model_name=model_name, + effective_from=effective_from, + effective_to=effective_to, + exclude_id=rule_id, + ) + rule.rule_json = rule_json + rule.rule_content_hash = build_rule_content_hash( + model_category=model_category, + billing_mode=billing_mode, + calculator_version=calculator_version, + currency=currency, + rule_schema_version=rule_schema_version, + rule_json=rule_json, + ) + rule.publish_status = ModelPricingRuleStatus.PUBLISHED.value + rule.updated_by = operator_id + await db.flush() + return await get_rule_snapshot(db, rule_id) + + +async def disable_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]: + rule = await get_rule(db, rule_id, for_update=True) + if rule.publish_status == ModelPricingRuleStatus.DISABLED.value: + return await get_rule_snapshot(db, rule_id) + await _lock_model_rule_namespace(db, rule.provider, rule.model_name) + if rule.publish_status == ModelPricingRuleStatus.PUBLISHED.value: + now = datetime.now(timezone.utc) + close_at = now if rule.effective_from < now else rule.effective_from + if rule.effective_to is None or rule.effective_to > close_at: + rule.effective_to = close_at + rule.publish_status = ModelPricingRuleStatus.DISABLED.value + rule.updated_by = operator_id + await db.flush() + return await get_rule_snapshot(db, rule_id) diff --git a/video-gen-api/app/services/model_pricing/seed_data.py b/video-gen-api/app/services/model_pricing/seed_data.py new file mode 100644 index 00000000..97e0d194 --- /dev/null +++ b/video-gen-api/app/services/model_pricing/seed_data.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from app.enums.model_pricing import ( + ModelPricingBillingMode, + ModelPricingCalculatorVersion, + ModelPricingCategory, + ModelPricingProvider, + PricingBillBy, +) + + +CST = timezone(timedelta(hours=8)) +SOURCE_URL = "https://www.volcengine.com/docs/82379/1544106" +SOURCE_UPDATED_AT = datetime(2026, 7, 9, 12, 2, 6, tzinfo=CST) + +# 文档更新时间不等于价格真实生效时间。初始化仅创建草稿;提交前必须逐模型核实并修改。 +PROPOSED_EFFECTIVE_FROM = SOURCE_UPDATED_AT + + +def volcengine_pricing_seed_rules() -> list[dict[str, Any]]: + common = { + "provider": ModelPricingProvider.VOLCENGINE.value, + "publish_status": "draft", + "currency": "CNY", + "rule_schema_version": 1, + "source_url": SOURCE_URL, + "source_updated_at": SOURCE_UPDATED_AT, + "effective_from": PROPOSED_EFFECTIVE_FROM, + "remark_prefix": "初始化草稿:effective_from 仅为建议值,发布前必须核对火山真实生效时间。", + } + rules = [ + { + **common, + "model_name": "doubao-seed-2-0-lite-260215", + "model_category": ModelPricingCategory.TEXT.value, + "billing_mode": ModelPricingBillingMode.TEXT_TOKEN_TIERED.value, + "calculator_version": ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_million_tokens", + "cache_storage_rate_per_million_token_hour": "0.017", + "tiers": [ + {"max_context_tokens": 32000, "input_rate": "0.6", "audio_input_rate": "9", "output_rate": "3.6", "cached_input_rate": "0.12", "cached_audio_input_rate": "1.8"}, + {"max_context_tokens": 128000, "input_rate": "0.9", "audio_input_rate": "13.5", "output_rate": "5.4", "cached_input_rate": "0.18", "cached_audio_input_rate": "2.7"}, + {"max_context_tokens": 256000, "input_rate": "1.8", "audio_input_rate": "27", "output_rate": "10.8", "cached_input_rate": "0.36", "cached_audio_input_rate": "5.4"}, + ], + }, + "remark": "豆包 Seed 2.0 Lite,按上下文长度分档。", + }, + { + **common, + "model_name": "doubao-seedream-5-0-pro-260628", + "model_category": ModelPricingCategory.IMAGE.value, + "billing_mode": ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value, + "calculator_version": ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_image", + "free_input_images": 1, + "input_image_rate": "0.02", + "output_tiers": [ + {"max_pixels": 2360000, "rate": "0.30"}, + {"max_pixels": None, "rate": "0.60"}, + ], + "bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value, + }, + "remark": "Seedream 5.0 Pro:输入图和逐张输出像素分档计价。", + }, + { + **common, + "model_name": "doubao-seedream-5-0-260128", + "model_category": ModelPricingCategory.IMAGE.value, + "billing_mode": ModelPricingBillingMode.IMAGE_PER_OUTPUT.value, + "calculator_version": ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": {"unit": "CNY_per_image", "output_rate": "0.22", "bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value}, + "remark": "Seedream 5.0:按同步接口实际成功输出图片数量计价。", + }, + { + **common, + "model_name": "doubao-seedance-2-0-260128", + "model_category": ModelPricingCategory.VIDEO.value, + "billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value, + "calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_million_tokens", + "token_formula_description": "(input_video_seconds + output_video_seconds) * width * height * fps / 1024", + "default_fps": 30, + "dimension_map": {}, + "rates": [ + {"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "46"}, + {"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "28"}, + {"resolutions": ["1080p"], "has_input_video": False, "rate": "51"}, + {"resolutions": ["1080p"], "has_input_video": True, "rate": "31"}, + {"resolutions": ["4k"], "has_input_video": False, "rate": "26"}, + {"resolutions": ["4k"], "has_input_video": True, "rate": "16"}, + ], + }, + "remark": "Seedance 2.0 标准版;dimension_map 空时只接受 Provider 实际 Token,不猜比例像素。", + }, + { + **common, + "model_name": "doubao-seedance-2-0-fast-260128", + "model_category": ModelPricingCategory.VIDEO.value, + "billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value, + "calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_million_tokens", + "default_fps": 30, + "dimension_map": {}, + "rates": [ + {"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "37"}, + {"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "22"}, + ], + }, + "remark": "Seedance 2.0 Fast;仅配置支持的价格档位。", + }, + { + **common, + "model_name": "doubao-seedance-2-0-mini-260615", + "model_category": ModelPricingCategory.VIDEO.value, + "billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value, + "calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_million_tokens", + "default_fps": 30, + "dimension_map": {}, + "rates": [ + {"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "23"}, + {"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "14"}, + ], + }, + "remark": "Seedance 2.0 Mini;仅配置支持的价格档位。", + }, + { + **common, + "model_name": "doubao-seedance-1-5-pro-251215", + "model_category": ModelPricingCategory.VIDEO.value, + "billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value, + "calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": { + "unit": "CNY_per_million_tokens", + "default_fps": 30, + "dimension_map": {}, + "rates": [ + {"inference_modes": ["online"], "generate_audio": False, "rate": "8"}, + {"inference_modes": ["online"], "generate_audio": True, "rate": "16"}, + {"inference_modes": ["flex", "batch"], "generate_audio": False, "rate": "4"}, + {"inference_modes": ["flex", "batch"], "generate_audio": True, "rate": "8"}, + ], + }, + "remark": "Seedance 1.5 Pro,按推理模式与有声/无声选择 Token 单价。", + }, + { + **common, + "model_name": "doubao-seedance-1-0-pro-250528", + "model_category": ModelPricingCategory.VIDEO.value, + "billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value, + "calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, + "version_code": "volc_20260709_v1", + "rule_json": {"unit": "CNY_per_million_tokens", "default_fps": 30, "dimension_map": {}, "rates": [{"rate": "15"}]}, + "remark": "Seedance 1.0 Pro 固定 Token 单价。", + }, + ] + for item in rules: + prefix = item.pop("remark_prefix") + item["remark"] = f"{prefix} {item.get('remark') or ''}".strip() + return rules diff --git a/video-gen-api/app/services/model_pricing/snapshot_service.py b/video-gen-api/app/services/model_pricing/snapshot_service.py new file mode 100644 index 00000000..f50e1f3e --- /dev/null +++ b/video-gen-api/app/services/model_pricing/snapshot_service.py @@ -0,0 +1,585 @@ +from __future__ import annotations + +import hashlib +import json +from copy import deepcopy +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any, Mapping + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.model_pricing import ModelPricingRuleStatus, ProviderCostStatus, PricingSnapshotStage +from app.models.credit_record import CreditRecord +from app.models.model_pricing_rule import ModelPricingRule +from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing +from app.services.model_pricing.rule_service import normalize_provider, resolve_published_rule +from app.services.operation_log_service import log_model_pricing_event + + +SNAPSHOT_SCHEMA_VERSION = 1 +FINAL_STAGES = { + PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value, + PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value, + PricingSnapshotStage.BACKFILL.value, +} + + +def _json_default(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, Decimal): + return str(value) + return str(value) + + +def _canonical_hash(value: Mapping[str, Any]) -> str: + raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _reference_at(value: datetime | None) -> datetime: + value = value or _utcnow() + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + + +def build_refund_pricing_snapshot(charge: Any) -> tuple[dict[str, Any], str]: + snapshot = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "refund": { + "provider_cost": { + "currency": getattr(charge, "provider_cost_currency", None) or "CNY", + "amount": "0", + "status": ProviderCostStatus.NOT_APPLICABLE.value, + "reason": "user_credit_refund_does_not_reverse_provider_cost", + }, + "original_charge": { + "credit_record_id": getattr(charge, "id", None), + "pricing_rule_id": getattr(charge, "pricing_rule_id", None), + "pricing_version_code": getattr(charge, "pricing_version_code", None), + "pricing_snapshot_hash": getattr(charge, "pricing_snapshot_hash", None), + "provider_cost_amount": str(getattr(charge, "provider_cost_amount", None) or 0), + "provider_cost_status": getattr(charge, "provider_cost_status", None), + }, + }, + } + return snapshot, _canonical_hash(snapshot) + + +def _rule_snapshot(rule: ModelPricingRule) -> dict[str, Any]: + return { + "id": rule.id, + "provider": rule.provider, + "model_name": rule.model_name, + "model_category": rule.model_category, + "billing_mode": rule.billing_mode, + "calculator_version": rule.calculator_version, + "version_code": rule.version_code, + "effective_from": rule.effective_from.isoformat() if rule.effective_from else None, + "effective_to": rule.effective_to.isoformat() if rule.effective_to else None, + "currency": rule.currency, + "rule_schema_version": rule.rule_schema_version, + "rule_content_hash": rule.rule_content_hash, + "rule_json": deepcopy(rule.rule_json or {}), + "source_url": rule.source_url, + "source_updated_at": rule.source_updated_at.isoformat() if rule.source_updated_at else None, + } + + +def _apply_rule_fields(target: Any, rule: ModelPricingRule, reference_at: datetime) -> None: + target.pricing_rule_id = rule.id + target.pricing_version_code = rule.version_code + target.pricing_billing_mode = rule.billing_mode + target.pricing_calculator_version = rule.calculator_version + target.pricing_reference_at = reference_at + target.pricing_effective_from = rule.effective_from + target.pricing_effective_to = rule.effective_to + target.pricing_snapshot_schema_version = SNAPSHOT_SCHEMA_VERSION + target.provider_cost_currency = rule.currency + + +def _build_pricing_snapshot( + *, + rule: ModelPricingRule, + result: Any, + stage: str, + audit_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + # calculated_at 不参与 hash;运行时间单独保存在平铺字段中,保证相同规则/用量快照 hash 稳定。 + snapshot = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "stage": stage, + "rule": _rule_snapshot(rule), + "calculation": deepcopy(result.breakdown), + "provider_cost": { + "currency": result.currency, + "amount": str(result.amount), + "status": ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value, + "is_estimated": bool(result.is_estimated), + "usage_source": result.usage_source, + }, + } + if audit_metadata: + snapshot["backfill"] = deepcopy(dict(audit_metadata)) + return snapshot + + +def _build_status_snapshot( + *, + status: str, + stage: str, + rule: ModelPricingRule | None = None, + reason: str | None = None, + audit_metadata: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + snapshot: dict[str, Any] = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "stage": stage, + "provider_cost": {"status": status}, + } + if rule is not None: + snapshot["rule"] = _rule_snapshot(rule) + snapshot["provider_cost"]["currency"] = rule.currency + if reason: + snapshot["provider_cost"]["reason"] = reason + if audit_metadata: + snapshot["backfill"] = deepcopy(dict(audit_metadata)) + return snapshot + + +def _apply_result( + target: Any, + *, + rule: ModelPricingRule, + usage: Mapping[str, Any], + result: Any, + stage: str, + audit_metadata: Mapping[str, Any] | None = None, +) -> None: + now = _utcnow() + status = ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value + pricing_snapshot = _build_pricing_snapshot( + rule=rule, + result=result, + stage=stage, + audit_metadata=audit_metadata, + ) + target.provider_cost_amount = result.amount + target.provider_cost_status = status + target.provider_cost_is_estimated = bool(result.is_estimated) + target.provider_cost_calculated_at = now + target.provider_cost_finalized_at = now if stage in FINAL_STAGES else None + target.pricing_usage_source = result.usage_source + target.pricing_snapshot_json = pricing_snapshot + target.usage_snapshot_json = deepcopy(dict(usage)) + target.pricing_snapshot_hash = _canonical_hash(pricing_snapshot) + + +def _can_calculate(billing_mode: str, usage: Mapping[str, Any]) -> bool: + if billing_mode == "text_token_tiered": + return any(int(usage.get(k) or 0) > 0 for k in ("input_tokens", "output_tokens", "cached_input_tokens", "audio_input_tokens")) + if billing_mode in {"image_per_output", "image_input_output_tiered"}: + return int(usage.get("successful_output_count") or usage.get("provider_billed_count") or 0) > 0 + if billing_mode == "video_token_rate": + if int(usage.get("total_tokens") or 0) > 0: + return True + return float(usage.get("output_video_duration_seconds") or 0) > 0 + return False + + +def _apply_not_applicable(target: Any, usage: Mapping[str, Any], reason: str) -> None: + target.provider_cost_status = ProviderCostStatus.NOT_APPLICABLE.value + target.provider_cost_amount = Decimal("0") + target.provider_cost_is_estimated = False + target.provider_usage_primary = False + target.usage_snapshot_json = deepcopy(dict(usage)) or None + target.pricing_snapshot_json = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "provider_cost": { + "status": ProviderCostStatus.NOT_APPLICABLE.value, + "amount": "0", + "reason": reason, + }, + } + target.pricing_snapshot_hash = _canonical_hash(target.pricing_snapshot_json) + + +async def enrich_credit_meta_with_pricing( + db: AsyncSession, + *, + meta: Any, + usage: Mapping[str, Any] | None = None, + reference_at: datetime | None = None, + final: bool = False, +) -> Any: + usage_dict = deepcopy(dict(usage or {})) + reference = _reference_at(reference_at) + + if getattr(meta, "charge_kind", None) in {"file_parse", "vision_input"}: + meta.pricing_reference_at = reference + _apply_not_applicable(meta, usage_dict, "cost_included_in_primary_text_prompt_charge") + return meta + + provider = getattr(meta, "engine_provider", None) + model_name = getattr(meta, "engine_model_name", None) + if not provider or not model_name: + meta.pricing_reference_at = reference + meta.provider_cost_status = ProviderCostStatus.PENDING.value if not final else ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value + meta.usage_snapshot_json = usage_dict or None + return meta + + rule = await resolve_published_rule(db, provider=provider, model_name=model_name, reference_at=reference) + if not rule: + meta.pricing_reference_at = reference + meta.provider_cost_status = ProviderCostStatus.UNMATCHED_RULE.value + meta.usage_snapshot_json = usage_dict or None + return meta + + _apply_rule_fields(meta, rule, reference) + meta.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True)) + meta.usage_snapshot_json = usage_dict or None + + # 媒体扣费创建阶段只锁定规则与请求快照。图片等待同步生成响应,视频等待异步 Provider 完成; + # 不能在请求时用预设时长/分辨率提前写入估算成本。 + if not final: + meta.provider_cost_status = ProviderCostStatus.PENDING.value + meta.provider_cost_amount = None + meta.provider_cost_is_estimated = False + meta.pricing_snapshot_json = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "stage": PricingSnapshotStage.REQUEST_LOCKED.value, + "rule": _rule_snapshot(rule), + "provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status}, + } + meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json) + return meta + + if not _can_calculate(rule.billing_mode, usage_dict): + meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value + meta.pricing_snapshot_json = { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "stage": PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value, + "rule": _rule_snapshot(rule), + "provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status}, + } + meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json) + return meta + + try: + result = calculate_pricing( + billing_mode=rule.billing_mode, + calculator_version=rule.calculator_version, + rule_json=rule.rule_json or {}, + usage=usage_dict, + currency=rule.currency, + ) + _apply_result( + meta, + rule=rule, + usage=usage_dict, + result=result, + stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value if final else PricingSnapshotStage.REQUEST_LOCKED.value, + ) + except PricingCalculationError: + meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value if final else ProviderCostStatus.PENDING.value + except Exception as exc: + meta.provider_cost_status = ProviderCostStatus.ERROR.value + log_model_pricing_event( + event_type="pricing_cost_calculate", + event_status="failed", + owner_type=getattr(meta, "owner_type", None), + owner_id=getattr(meta, "owner_id", None), + pricing_rule_id=rule.id, + pricing_version=rule.version_code, + provider=provider, + model_name=model_name, + billing_mode=rule.billing_mode, + cost_status=meta.provider_cost_status, + error=str(exc), + ) + return meta + + +async def _load_locked_rule( + db: AsyncSession, + charge: CreditRecord, + *, + reference_at: datetime, + use_locked_rule: bool, +) -> ModelPricingRule | None: + if use_locked_rule and charge.pricing_rule_id: + return ( + await db.execute(select(ModelPricingRule).where(ModelPricingRule.id == charge.pricing_rule_id).limit(1)) + ).scalar_one_or_none() + + if not use_locked_rule: + provider = normalize_provider(charge.engine_provider, charge.engine_model_name) + model_name = str(charge.engine_model_name or "").strip() + if not provider or not model_name: + return None + return ( + await db.execute( + select(ModelPricingRule) + .where(ModelPricingRule.provider == provider) + .where(ModelPricingRule.model_name == model_name) + .where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value) + .where(ModelPricingRule.effective_from <= reference_at) + .where( + (ModelPricingRule.effective_to.is_(None)) + | (ModelPricingRule.effective_to > reference_at) + ) + .order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + return await resolve_published_rule( + db, + provider=charge.engine_provider, + model_name=charge.engine_model_name, + reference_at=reference_at, + ) + + +async def _missing_rule_status(db: AsyncSession, *, charge: CreditRecord, reference_at: datetime) -> str: + model_name = str(charge.engine_model_name or "").strip() + provider = normalize_provider(charge.engine_provider, model_name) + if not provider or not model_name: + return ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value + earliest = ( + await db.execute( + select(func.min(ModelPricingRule.effective_from)).where( + ModelPricingRule.provider == provider, + ModelPricingRule.model_name == model_name, + ) + ) + ).scalar_one_or_none() + if earliest and _reference_at(reference_at) < _reference_at(earliest): + return ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value + return ProviderCostStatus.UNMATCHED_RULE.value + + +def _merge_snapshot(existing: Mapping[str, Any] | None, incoming: Mapping[str, Any] | None) -> dict[str, Any] | None: + if incoming is None: + return deepcopy(dict(existing or {})) or None + merged = deepcopy(dict(existing or {})) + merged.update(deepcopy(dict(incoming))) + return merged + + +async def finalize_credit_record_pricing( + db: AsyncSession, + *, + charge: CreditRecord, + usage: Mapping[str, Any] | None, + stage: str, + attachment_snapshot: Mapping[str, Any] | None = None, + attachment_counts: Mapping[str, Any] | None = None, + generation_snapshot: Mapping[str, Any] | None = None, + generation_counts: Mapping[str, Any] | None = None, + allow_upgrade_estimated: bool = True, + pricing_reference_at: datetime | None = None, + use_locked_rule: bool = True, + force_reprice: bool = False, + backfill_metadata: Mapping[str, Any] | None = None, +) -> CreditRecord: + """同一事务内回填,不 commit;JSON 一律构建新对象后整体赋值。 + + 正常生成链路保持默认行为:使用请求时已锁定的规则,已核算成本不可覆盖。 + 历史补录可显式传入统一的当前计价时点、忽略旧规则绑定并强制重算。 + """ + if attachment_snapshot is not None: + charge.attachment_snapshot_json = deepcopy(dict(attachment_snapshot)) + for key, value in (attachment_counts or {}).items(): + if hasattr(charge, key): + setattr(charge, key, value) + if generation_snapshot is not None: + charge.generation_snapshot_json = _merge_snapshot(charge.generation_snapshot_json, generation_snapshot) + for key, value in (generation_counts or {}).items(): + if hasattr(charge, key): + setattr(charge, key, value) + + # 资源下载完成只补资源快照;同步图片/异步视频成本均不得在下载阶段重算。 + if stage == PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value: + return charge + + if charge.type != "consume" or charge.charge_action != "charge": + _apply_not_applicable(charge, dict(usage or {}), "only_consume_charge_can_be_priced") + return charge + + current_status = charge.provider_cost_status + if not force_reprice: + if current_status == ProviderCostStatus.CALCULATED.value: + return charge + if current_status == ProviderCostStatus.ESTIMATED.value and not allow_upgrade_estimated: + return charge + + usage_dict = deepcopy(dict(usage or {})) + reference = _reference_at( + pricing_reference_at + if pricing_reference_at is not None + else (charge.pricing_reference_at or charge.created_at) + ) + rule = await _load_locked_rule( + db, + charge, + reference_at=reference, + use_locked_rule=use_locked_rule, + ) + if not rule: + charge.pricing_reference_at = reference + if not charge.engine_provider or not charge.engine_model_name: + status = ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value + elif use_locked_rule: + status = await _missing_rule_status(db, charge=charge, reference_at=reference) + else: + status = ProviderCostStatus.UNMATCHED_RULE.value + charge.provider_cost_status = status + charge.provider_cost_amount = None + charge.provider_cost_is_estimated = False + charge.provider_cost_calculated_at = None + charge.provider_cost_finalized_at = None + charge.usage_snapshot_json = usage_dict or None + charge.pricing_snapshot_json = _build_status_snapshot( + status=status, + stage=stage, + reason="current_published_rule_not_found" if not use_locked_rule else "pricing_rule_not_found", + audit_metadata=backfill_metadata, + ) + charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json) + return charge + + _apply_rule_fields(charge, rule, reference) + charge.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True)) + if not _can_calculate(rule.billing_mode, usage_dict): + charge.provider_cost_status = ( + ProviderCostStatus.USAGE_MISSING.value + if stage in FINAL_STAGES + else ProviderCostStatus.PENDING.value + ) + charge.provider_cost_amount = None + charge.provider_cost_is_estimated = False + charge.provider_cost_calculated_at = None + charge.provider_cost_finalized_at = None + charge.usage_snapshot_json = usage_dict or None + charge.pricing_snapshot_json = _build_status_snapshot( + status=charge.provider_cost_status, + stage=stage, + rule=rule, + reason="pricing_usage_missing", + audit_metadata=backfill_metadata, + ) + charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json) + return charge + + try: + result = calculate_pricing( + billing_mode=rule.billing_mode, + calculator_version=rule.calculator_version, + rule_json=rule.rule_json or {}, + usage=usage_dict, + currency=rule.currency, + ) + if ( + not force_reprice + and current_status == ProviderCostStatus.ESTIMATED.value + and result.is_estimated + ): + return charge + _apply_result( + charge, + rule=rule, + usage=usage_dict, + result=result, + stage=stage, + audit_metadata=backfill_metadata, + ) + log_model_pricing_event( + event_type="pricing_snapshot_persist", + user_id=charge.user_id, + credit_record_id=charge.id, + owner_type=charge.owner_type, + owner_id=charge.owner_id, + pricing_rule_id=rule.id, + pricing_version=rule.version_code, + provider=charge.engine_provider, + model_name=charge.engine_model_name, + billing_mode=rule.billing_mode, + cost_status=charge.provider_cost_status, + provider_cost=charge.provider_cost_amount, + is_estimated=charge.provider_cost_is_estimated, + detail={ + "stage": stage, + "usage_source": charge.pricing_usage_source, + "backfill": deepcopy(dict(backfill_metadata or {})) or None, + }, + ) + except PricingCalculationError as exc: + charge.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value + charge.provider_cost_amount = None + charge.provider_cost_is_estimated = False + charge.provider_cost_calculated_at = None + charge.provider_cost_finalized_at = None + charge.usage_snapshot_json = usage_dict or None + charge.pricing_snapshot_json = _build_status_snapshot( + status=charge.provider_cost_status, + stage=stage, + rule=rule, + reason=str(exc), + audit_metadata=backfill_metadata, + ) + charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json) + log_model_pricing_event( + event_type="pricing_snapshot_failed", + event_status="warning", + user_id=charge.user_id, + credit_record_id=charge.id, + owner_type=charge.owner_type, + owner_id=charge.owner_id, + pricing_rule_id=rule.id, + pricing_version=rule.version_code, + provider=charge.engine_provider, + model_name=charge.engine_model_name, + billing_mode=rule.billing_mode, + cost_status=charge.provider_cost_status, + error=str(exc), + detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None}, + ) + except Exception as exc: + charge.provider_cost_status = ProviderCostStatus.ERROR.value + charge.provider_cost_amount = None + charge.provider_cost_is_estimated = False + charge.provider_cost_calculated_at = None + charge.provider_cost_finalized_at = None + charge.usage_snapshot_json = usage_dict or None + charge.pricing_snapshot_json = _build_status_snapshot( + status=charge.provider_cost_status, + stage=stage, + rule=rule, + reason=str(exc), + audit_metadata=backfill_metadata, + ) + charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json) + log_model_pricing_event( + event_type="pricing_snapshot_failed", + event_status="failed", + user_id=charge.user_id, + credit_record_id=charge.id, + owner_type=charge.owner_type, + owner_id=charge.owner_id, + pricing_rule_id=rule.id, + pricing_version=rule.version_code, + provider=charge.engine_provider, + model_name=charge.engine_model_name, + billing_mode=rule.billing_mode, + cost_status=charge.provider_cost_status, + error=str(exc), + detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None}, + ) + return charge + diff --git a/video-gen-api/app/services/model_pricing/usage_normalizer.py b/video-gen-api/app/services/model_pricing/usage_normalizer.py new file mode 100644 index 00000000..9e2f80ed --- /dev/null +++ b/video-gen-api/app/services/model_pricing/usage_normalizer.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +import hashlib +import json +import re +from copy import deepcopy +from typing import Any, Mapping +from urllib.parse import urlsplit, urlunsplit + + +def safe_json_dict(value: Any) -> dict[str, Any]: + """Best-effort JSON object conversion without leaking parse failures into billing.""" + if isinstance(value, Mapping): + return deepcopy(dict(value)) + if isinstance(value, str) and value.strip(): + try: + parsed = json.loads(value) + return deepcopy(dict(parsed)) if isinstance(parsed, Mapping) else {} + except Exception: + return {} + return {} + + +def safe_int(value: Any, default: int = 0) -> int: + try: + if value in (None, ""): + return default + return int(float(value)) + except Exception: + return default + + +def safe_float(value: Any, default: float = 0.0) -> float: + try: + if value in (None, ""): + return default + return float(value) + except Exception: + return default + + +def safe_bool(value: Any, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if value in (None, ""): + return default + if isinstance(value, (int, float)): + return value != 0 + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on", "enabled"}: + return True + if text in {"0", "false", "no", "off", "disabled"}: + return False + return default + + +def parse_size(value: Any) -> tuple[int, int]: + text = str(value or "").lower().replace("×", "x") + match = re.search(r"(\d{2,5})\s*x\s*(\d{2,5})", text) + if not match: + return 0, 0 + return int(match.group(1)), int(match.group(2)) + + +def _extract_usage(data: Mapping[str, Any]) -> dict[str, Any]: + candidates = [ + data.get("usage"), + (data.get("data") or {}).get("usage") if isinstance(data.get("data"), Mapping) else None, + (data.get("result") or {}).get("usage") if isinstance(data.get("result"), Mapping) else None, + ] + for value in candidates: + if isinstance(value, Mapping): + return deepcopy(dict(value)) + return {} + + +def normalize_text_pricing_usage( + raw_usage: Mapping[str, Any] | None, + *, + base: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Normalize Ark/OpenAI text usage and retain cache/audio dimensions.""" + raw = deepcopy(dict(raw_usage or {})) + result = deepcopy(dict(base or {})) + + input_tokens = safe_int(result.get("input_tokens"), safe_int(raw.get("input_tokens"), safe_int(raw.get("prompt_tokens")))) + output_tokens = safe_int(result.get("output_tokens"), safe_int(raw.get("output_tokens"), safe_int(raw.get("completion_tokens")))) + total_tokens = safe_int(result.get("total_tokens"), safe_int(raw.get("total_tokens"), input_tokens + output_tokens)) + + details: dict[str, Any] = {} + for key in ("prompt_tokens_details", "input_tokens_details"): + value = raw.get(key) + if isinstance(value, Mapping): + details.update(deepcopy(dict(value))) + + cached_input_tokens = safe_int( + result.get("cached_input_tokens"), + safe_int(raw.get("cached_input_tokens"), safe_int(details.get("cached_tokens"), safe_int(details.get("cache_read_tokens")))), + ) + audio_input_tokens = safe_int( + result.get("audio_input_tokens"), + safe_int(raw.get("audio_input_tokens"), safe_int(details.get("audio_tokens"))), + ) + cached_audio_input_tokens = safe_int( + result.get("cached_audio_input_tokens"), + safe_int(raw.get("cached_audio_input_tokens"), safe_int(details.get("cached_audio_tokens"))), + ) + + result.update( + { + "input_tokens": max(0, input_tokens), + "output_tokens": max(0, output_tokens), + "total_tokens": max(0, total_tokens), + "context_tokens": max(0, safe_int(result.get("context_tokens"), input_tokens)), + "cached_input_tokens": max(0, min(input_tokens, cached_input_tokens)), + "audio_input_tokens": max(0, min(input_tokens, audio_input_tokens)), + "cached_audio_input_tokens": max(0, min(input_tokens, audio_input_tokens, cached_audio_input_tokens)), + "cache_storage_tokens": max(0, safe_int(result.get("cache_storage_tokens"), safe_int(raw.get("cache_storage_tokens")))), + "cache_storage_duration_hours": max( + 0.0, + safe_float(result.get("cache_storage_duration_hours"), safe_float(raw.get("cache_storage_duration_hours"))), + ), + "provider_usage_primary": safe_bool(result.get("provider_usage_primary"), True), + "usage_source": result.get("usage_source") or "provider", + } + ) + if details: + result["provider_input_token_details"] = details + return result + + +def extract_image_output_items(provider_response: Any) -> list[dict[str, Any]]: + """Only parse explicit synchronous image output items; never recurse through arbitrary URLs.""" + data = safe_json_dict(provider_response) + raw_items = data.get("data") + if isinstance(raw_items, Mapping): + raw_items = raw_items.get("items") or raw_items.get("data") + if not isinstance(raw_items, list): + raw_items = (data.get("result") or {}).get("data") if isinstance(data.get("result"), Mapping) else None + if not isinstance(raw_items, list): + return [] + + items: list[dict[str, Any]] = [] + for index, raw in enumerate(raw_items): + if not isinstance(raw, Mapping): + continue + url = raw.get("url") or raw.get("image_url") + width = safe_int(raw.get("width")) + height = safe_int(raw.get("height")) + if width <= 0 or height <= 0: + width, height = parse_size(raw.get("size")) + item = { + "index": index, + "url": str(url) if url else None, + "width": width or None, + "height": height or None, + "pixels": width * height if width > 0 and height > 0 else None, + "size": raw.get("size"), + "size_source": "provider_response" if width > 0 and height > 0 else "unavailable", + } + # A valid provider output may omit a URL in rare response formats, but it must + # still be represented for generated-count and pixel-tier accounting. + items.append({key: value for key, value in item.items() if value is not None}) + return items + + +def sanitize_output_items(items: list[Mapping[str, Any]]) -> list[dict[str, Any]]: + """Remove volatile signed URLs before persisting pricing/generation snapshots.""" + sanitized: list[dict[str, Any]] = [] + for raw in items: + item = deepcopy(dict(raw)) + url = str(item.pop("url", "") or "").strip() + if url: + try: + parts = urlsplit(url) + normalized = ( + urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", "")) + if parts.scheme and parts.netloc + else url.split("?", 1)[0].split("#", 1)[0] + ) + except Exception: + normalized = url.split("?", 1)[0].split("#", 1)[0] + item["url_sha256"] = hashlib.sha256(normalized.encode("utf-8")).hexdigest() + sanitized.append(item) + return sanitized + + +def normalize_provider_media_usage( + provider_response: Any, + *, + gen_type: str, + fallback_total_tokens: int = 0, + request_image_px: str | None = None, + requested_output_count: int = 1, + provider_input_image_count: int = 0, +) -> dict[str, Any]: + """Normalize Volcengine synchronous-image or asynchronous-video response usage.""" + data = safe_json_dict(provider_response) + raw_usage = _extract_usage(data) + input_tokens = safe_int(raw_usage.get("input_tokens"), safe_int(raw_usage.get("prompt_tokens"))) + output_tokens = safe_int( + raw_usage.get("output_tokens"), + safe_int(raw_usage.get("completion_tokens"), safe_int(raw_usage.get("generated_tokens"))), + ) + total_tokens = safe_int(raw_usage.get("total_tokens"), fallback_total_tokens) + if total_tokens <= 0: + total_tokens = input_tokens + output_tokens + if output_tokens <= 0 and total_tokens > input_tokens: + output_tokens = total_tokens - input_tokens + + result: dict[str, Any] = deepcopy(dict(raw_usage)) + result.update( + { + "input_tokens": max(0, input_tokens), + "output_tokens": max(0, output_tokens), + "total_tokens": max(0, total_tokens), + "requested_output_count": max(1, requested_output_count), + "provider_usage_primary": True, + } + ) + + pricing_meta = data.get("pricing_meta") if isinstance(data.get("pricing_meta"), Mapping) else {} + if gen_type == "image": + output_items = extract_image_output_items(data) + fallback_width, fallback_height = parse_size(request_image_px) + for item in output_items: + if not item.get("width") and fallback_width > 0 and fallback_height > 0: + item.update( + { + "width": fallback_width, + "height": fallback_height, + "pixels": fallback_width * fallback_height, + "size_source": "request_explicit", + } + ) + output_items = sanitize_output_items(output_items) + provider_count = safe_int( + pricing_meta.get("provider_input_image_count"), + safe_int(data.get("provider_input_image_count"), provider_input_image_count), + ) + generated = len(output_items) or safe_int(raw_usage.get("generated_images")) + provider_billed = safe_int( + raw_usage.get("billed_images"), + safe_int(pricing_meta.get("provider_billed_count"), max(0, generated)), + ) + result.update( + { + "provider_input_image_count": max(0, provider_count), + "input_image_count": max(0, provider_count), + "output_items": output_items, + "successful_output_count": max(0, generated), + "provider_billed_count": max(0, provider_billed), + "usage_source": "provider_response", + } + ) + return result + + width = safe_int(raw_usage.get("width"), safe_int(data.get("width"))) + height = safe_int(raw_usage.get("height"), safe_int(data.get("height"))) + if width <= 0 or height <= 0: + width, height = parse_size(raw_usage.get("size") or data.get("size")) + result.update( + { + "output_width": width, + "output_height": height, + "dimension_source": "provider_response" if width > 0 and height > 0 else "unavailable", + "fps": safe_float(raw_usage.get("fps"), safe_float(data.get("fps"))), + "resolution": str(raw_usage.get("resolution") or data.get("resolution") or "").lower(), + "aspect_ratio": str(raw_usage.get("aspect_ratio") or data.get("aspect_ratio") or data.get("ratio") or ""), + "generate_audio": safe_bool(raw_usage.get("generate_audio"), safe_bool(data.get("generate_audio"))), + "inference_mode": str(raw_usage.get("inference_mode") or data.get("service_tier") or "online").lower(), + "usage_source": "provider" if total_tokens > 0 else "provider_response", + } + ) + return result diff --git a/video-gen-api/app/services/operation_log_service.py b/video-gen-api/app/services/operation_log_service.py index 8e2c5056..3b8ffc85 100644 --- a/video-gen-api/app/services/operation_log_service.py +++ b/video-gen-api/app/services/operation_log_service.py @@ -364,3 +364,54 @@ def log_remote_api_event( error=remote_message if event_status == "failed" else None, **kwargs, ) + + +def log_model_pricing_event( + *, + event_type: str, + event_status: str = "success", + user_id: str | None = None, + credit_record_id: str | None = None, + owner_type: str | None = None, + owner_id: str | None = None, + pricing_rule_id: str | None = None, + pricing_version: str | None = None, + provider: str | None = None, + model_name: str | None = None, + billing_mode: str | None = None, + cost_status: str | None = None, + provider_cost: Any = None, + is_estimated: bool | None = None, + message: str | None = None, + detail: dict[str, Any] | None = None, + error: str | None = None, +) -> None: + """统一模型计价步骤日志,写入 log/OperationLogs/model_pricing/YYYY-MM-DD.log。""" + payload = dict(detail or {}) + payload.update( + { + "credit_record_id": credit_record_id, + "owner_type": owner_type, + "owner_id": owner_id, + "pricing_rule_id": pricing_rule_id, + "pricing_version": pricing_version, + "provider": provider, + "model_name": model_name, + "billing_mode": billing_mode, + "cost_status": cost_status, + "provider_cost": str(provider_cost) if provider_cost is not None else None, + "is_estimated": is_estimated, + } + ) + log_operation_event( + domain="model_pricing", + module="model_pricing", + event_type=event_type, + event_status=event_status, + source="model_pricing", + user_id=user_id, + task_id=owner_id, + message=message, + detail=payload, + error=error, + ) 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 10d14a6f..9df4ac34 100644 --- a/video-gen-api/app/services/shot_video_analysis_service.py +++ b/video-gen-api/app/services/shot_video_analysis_service.py @@ -19,6 +19,7 @@ from app.models.token_usage import TokenUsage from app.services.upload_video_asset_service import resolve_upload_video_path from app.services.resource_signed_url_service import build_resource_signed_url from app.utils.id_gen import generate_id +from app.services.model_pricing.usage_normalizer import normalize_text_pricing_usage from app.enums.common import LogEventStatusEnum, LogSourceEnum from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum from app.services.operation_log_service import log_ai_model_event @@ -692,7 +693,7 @@ async def analyze_video_for_shot_split( result = filter_and_normalize_breakdown(result, mode=mode) usage = raw.get("usage") or {} - token_usage = { + token_usage = normalize_text_pricing_usage(usage, base={ "input_tokens": _int_usage(usage.get("prompt_tokens") or usage.get("input_tokens")), "output_tokens": _int_usage(usage.get("completion_tokens") or usage.get("output_tokens")), "total_tokens": _int_usage(usage.get("total_tokens")), @@ -707,7 +708,7 @@ async def analyze_video_for_shot_split( "analysis_mode": mode, "trace_id": trace_id, "log_request": log_request_data, - } + }) if not token_usage["total_tokens"]: token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"] diff --git a/video-gen-api/app/services/video_queue.py b/video-gen-api/app/services/video_queue.py index 95687748..25d013dd 100644 --- a/video-gen-api/app/services/video_queue.py +++ b/video-gen-api/app/services/video_queue.py @@ -3,14 +3,21 @@ import json import logging import os from datetime import datetime +from types import SimpleNamespace from sqlalchemy import select from app.models.base import async_session from app.models.generation_record import GenerationRecord -from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response -from app.services.image_gen import get_active_image_engine, download_image -from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot +from app.models.image_engine import ImageEngine +from app.models.video_engine import VideoEngine +from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus +from app.services.video_gen import poll_task_status, download_video, _log_video_response +from app.services.image_gen import download_image, is_sync_image_provider_result_uncertain +from app.services.media_token_usage_snapshot_service import ( + mark_media_provider_cost_status, + sync_generation_record_media_token_snapshot, +) from app.services.resource_accounting_service import ( record_generation_record_generated_resource, safe_file_size, @@ -25,6 +32,38 @@ POLL_INTERVAL = 30 # seconds between polls MAX_POLLS = 60 # max 30 minutes total +def _loads(data: str | None) -> dict: + if not data: + return {} + try: + value = json.loads(data) + return value if isinstance(value, dict) else {} + except Exception: + return {} + + +async def _get_runtime_engine(db, record: GenerationRecord): + """使用生成开始时冻结的引擎快照,数据库行只读取当前密钥。""" + if not record.engine_id: + raise ValueError("生成记录缺少锁定的 engine_id") + snapshot = _loads(record.engine_snapshot_json) + model = ImageEngine if record.gen_type == "image" else VideoEngine + engine = (await db.execute(select(model).where(model.id == record.engine_id).limit(1))).scalar_one_or_none() + if not engine: + raise ValueError("锁定的生成引擎不存在") + return SimpleNamespace( + id=record.engine_id, + name=snapshot.get("name") or engine.name, + provider=snapshot.get("provider") or engine.provider, + api_base=snapshot.get("api_base") or engine.api_base, + api_key=engine.api_key, + model_name=snapshot.get("model_name") or engine.model_name, + generate_url=snapshot.get("generate_url") or getattr(engine, "generate_url", ""), + query_url=snapshot.get("query_url") or getattr(engine, "query_url", ""), + default_size=snapshot.get("default_size") or getattr(engine, "default_size", "2K"), + ) + + class TaskQueue: def __init__(self): self.queue: asyncio.Queue[str] = asyncio.Queue() @@ -93,7 +132,7 @@ class TaskQueue: async def _process_video(self, db, record): """Process video generation task.""" record_id = record.id - + if not record.seedance_task_id: await mark_generation_record_failed_and_refund_once( db, @@ -104,7 +143,7 @@ class TaskQueue: return try: - engine = await get_active_engine(db) + engine = await _get_runtime_engine(db, record) poll_result = await poll_task_status(engine, record.seedance_task_id) except Exception as e: logger.error(f"Poll error for {record_id}: {e}") @@ -133,6 +172,17 @@ class TaskQueue: if status == "succeeded": file_url = poll_result.get("video_url", "") + resp_data["video_url"] = file_url + resp_data["task_id"] = record.seedance_task_id + record.provider_response_json = json.dumps(resp_data, ensure_ascii=False, default=str) + record.video_tokens_used = poll_result.get("video_tokens", 0) + # 视频 Provider 已完成时核算供应商成本;下载失败不影响已发生的供应商费用。 + await sync_generation_record_media_token_snapshot( + db, + record, + provider_response=resp_data, + stage=PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value, + ) storage_path = None file_size_bytes = 0 if settings.STORAGE_TYPE == "local" and file_url: @@ -157,8 +207,6 @@ class TaskQueue: record.video_url = file_url else: record.video_url = file_url - record.video_tokens_used = poll_result.get("video_tokens", 0) - await sync_generation_record_media_token_snapshot(db, record, provider_response=resp_data) record.status = "completed" record.generated_at = datetime.now() if record.video_url: @@ -171,6 +219,12 @@ class TaskQueue: remote_url=file_url, generated_at=record.generated_at, ) + await sync_generation_record_media_token_snapshot( + db, + record, + provider_response=resp_data, + stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value, + ) self._active.pop(record_id, None) await db.commit() logger.info(f"Video task completed: {record_id}") @@ -207,8 +261,11 @@ class TaskQueue: record_id = record.id from app.services.image_gen import submit_image_task, _log_image_response + provider_call_started = False + provider_call_completed = False try: - engine = await get_active_image_engine(db) + engine = await _get_runtime_engine(db, record) + provider_call_started = True poll_result = await asyncio.to_thread( submit_image_task, db, @@ -216,9 +273,23 @@ class TaskQueue: record, include_media_references=False, ) + provider_call_completed = True if poll_result["error"] == "": remote_url = poll_result.get("image_url") + try: + provider_response = json.loads(poll_result.get("response_data") or "{}") + except (json.JSONDecodeError, TypeError): + provider_response = {} + record.provider_response_json = json.dumps(provider_response, ensure_ascii=False, default=str) + record.image_tokens_used = poll_result.get("image_tokens", 0) + # 火山图片接口为同步生成:最终响应返回后立即完成供应商成本核算。 + await sync_generation_record_media_token_snapshot( + db, + record, + provider_response=provider_response, + stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value, + ) storage_path = None file_size_bytes = 0 if settings.STORAGE_TYPE == "local" and remote_url: @@ -236,8 +307,6 @@ class TaskQueue: record.image_url = remote_url else: record.image_url = remote_url - record.image_tokens_used = poll_result.get("image_tokens", 0) - await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result) record.status = "completed" record.generated_at = datetime.now() if record.image_url: @@ -250,19 +319,46 @@ class TaskQueue: remote_url=remote_url, generated_at=record.generated_at, ) + await sync_generation_record_media_token_snapshot( + db, + record, + provider_response=provider_response, + stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value, + ) await db.commit() logger.info(f"Image task completed: {record_id}") else: + error_message = poll_result.get("error", "图片生成失败") + await mark_media_provider_cost_status( + db, + owner=record, + status=ProviderCostStatus.NOT_INCURRED.value, + reason=error_message, + usage_stage="provider_sync_failed", + ) await mark_generation_record_failed_and_refund_once( db, record=record, - error_message=poll_result.get("error", "图片生成失败"), + error_message=error_message, ) await db.commit() logger.info(f"Image task failed: {record_id}") _log_image_response(record_id, poll_result) except Exception as e: + if provider_call_started: + uncertain = provider_call_completed or is_sync_image_provider_result_uncertain(e) + await mark_media_provider_cost_status( + db, + owner=record, + status=( + ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value + if uncertain + else ProviderCostStatus.NOT_INCURRED.value + ), + reason=str(e), + usage_stage="provider_sync_exception", + ) await mark_generation_record_failed_and_refund_once( db, record=record, diff --git a/video-gen-api/app/tasks/celery_app.py b/video-gen-api/app/tasks/celery_app.py index 58e2435f..43f210a7 100644 --- a/video-gen-api/app/tasks/celery_app.py +++ b/video-gen-api/app/tasks/celery_app.py @@ -77,6 +77,8 @@ if broker_url: task_serializer="json", accept_content=["json"], result_serializer="json", + result_expires=max(60, int(settings.CELERY_RESULT_EXPIRES_SECONDS or 7200)), + task_store_errors_even_if_ignored=True, timezone="Asia/Shanghai", enable_utc=True, task_soft_time_limit=600, diff --git a/video-gen-api/app/tasks/generation_create_tasks.py b/video-gen-api/app/tasks/generation_create_tasks.py index 40ac7c47..995a6481 100644 --- a/video-gen-api/app/tasks/generation_create_tasks.py +++ b/video-gen-api/app/tasks/generation_create_tasks.py @@ -15,6 +15,7 @@ from app.enums.generation_task import ( GenerationMode, GenerationType, ) +from app.enums.model_pricing import PricingSnapshotStage, ProviderCostStatus from app.models.base import async_session from app.models.chat_generation_task import ChatGenerationTask from app.services.error_codes import extract_error_message @@ -22,7 +23,11 @@ from app.services.generation_log_service import log_task_event from app.services.generation_poll_schedule_service import ensure_video_poll_fields from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation_provider_service import create_provider_task -from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot +from app.services.image_gen import is_sync_image_provider_result_uncertain +from app.services.media_token_usage_snapshot_service import ( + mark_media_provider_cost_status, + sync_chat_generation_task_media_token_snapshot, +) from app.services.redis_registry_service import ensure_aware_utc from app.tasks.celery_app import celery_app @@ -171,6 +176,8 @@ async def _run(task_id: str): ): return + provider_call_started = False + provider_call_completed = False try: if not task.optimized_prompt: old_stage = task.pipeline_stage @@ -229,7 +236,9 @@ async def _run(task_id: str): to_stage=ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value, ) + provider_call_started = True created = await create_provider_task(db, task) + provider_call_completed = True provider_task_id = created.get("task_id") if provider_task_id: @@ -246,7 +255,13 @@ async def _run(task_id: str): ensure_ascii=False, default=str, ) - await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=task.provider_response_json) + if task.gen_type == GenerationType.IMAGE.value: + await sync_chat_generation_task_media_token_snapshot( + db, + task, + provider_response=task.provider_response_json, + stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value, + ) if task.remote_result_url and not task.seedance_task_id: # 同步图片路径:原 SDK 已经返回最终 URL。 @@ -302,6 +317,19 @@ async def _run(task_id: str): if task: error_message = extract_error_message(exc, "生成任务") if callable(extract_error_message) else str(exc) + if task.gen_type == GenerationType.IMAGE.value and provider_call_started: + uncertain = provider_call_completed or is_sync_image_provider_result_uncertain(exc) + await mark_media_provider_cost_status( + db, + owner=task, + status=( + ProviderCostStatus.PROVIDER_RESULT_UNCERTAIN.value + if uncertain + else ProviderCostStatus.NOT_INCURRED.value + ), + reason=error_message, + usage_stage="provider_sync_exception", + ) await mark_chat_generation_task_failed_and_refund_once( db, task=task, diff --git a/video-gen-api/app/tasks/generation_download_tasks.py b/video-gen-api/app/tasks/generation_download_tasks.py index 118170af..9a395ba9 100644 --- a/video-gen-api/app/tasks/generation_download_tasks.py +++ b/video-gen-api/app/tasks/generation_download_tasks.py @@ -19,6 +19,7 @@ from app.enums.generation_task import ( ChatGenerationTaskStatus, GenerationType, ) +from app.enums.model_pricing import PricingSnapshotStage from app.models.base import async_session from app.models.chat_generation_task import ChatGenerationTask from app.services.celery_download_recovery_service import ( @@ -547,7 +548,11 @@ async def _run(task_id: str): remote_url=task.remote_result_url, generated_at=task.generated_at, ) - await sync_chat_generation_task_media_token_snapshot(db, task) + await sync_chat_generation_task_media_token_snapshot( + db, + task, + stage=PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value, + ) from app.services.generation_module_hook_service import notify_chat_generation_task_finished diff --git a/video-gen-api/app/tasks/generation_poll_tasks.py b/video-gen-api/app/tasks/generation_poll_tasks.py index 31178aab..bf0ee4f6 100644 --- a/video-gen-api/app/tasks/generation_poll_tasks.py +++ b/video-gen-api/app/tasks/generation_poll_tasks.py @@ -16,6 +16,7 @@ from app.enums.generation_task import ( PROVIDER_FAILED_STATUSES, PROVIDER_SUCCESS_STATUSES, ) +from app.enums.model_pricing import PricingSnapshotStage from app.models.base import async_session from app.models.chat_generation_task import ChatGenerationTask from app.services.error_codes import extract_error_message @@ -368,7 +369,12 @@ async def _run(task_id: str, *, force_due: bool = False): task.video_tokens_used = poll_result.get("video_tokens", 0) or 0 task.provider_response_json = response_data - await sync_chat_generation_task_media_token_snapshot(db, task, provider_response=response_data) + await sync_chat_generation_task_media_token_snapshot( + db, + task, + provider_response=response_data, + stage=PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value, + ) if not task.remote_result_url: await _mark_failed(db, task, message="供应商任务成功但未返回结果URL", detail=poll_result)