+
+
总充值
+{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)}
+
+
+
+
+
+
+ } onClick={load}>刷新} loading={exporting} onClick={exportExcel}>下载 Excel
+
+ {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) =>
+ } onClick={() => setDetail(r)}>详情/试算
+ {r.publishStatus === 'draft' && }
+ } onClick={() => cloneRule(r)}>克隆新版本
+ {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); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
+ { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
+
+
+ } onClick={load}>刷新
+ } onClick={() => { setEditing(null); setFormOpen(true); }}>新增价格版本
+
+
+ { 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)