This commit is contained in:
2026-07-11 12:48:29 +08:00
parent c64cf06c33
commit 6cc1655c69
54 changed files with 7223 additions and 619 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-B0tEuCkU.js"></script>
<script type="module" crossorigin src="/assets/index-3wUbVp5v.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+2
View File
@@ -14,6 +14,7 @@ import AdminModels from './pages/AdminModels';
import AdminSettings from './pages/AdminSettings';
import AdminNotificationManager from './pages/AdminNotificationManager';
import AdminCreditRecords from './pages/AdminCreditRecords';
import AdminModelPricingRules from './pages/AdminModelPricingRules';
import AdminPaymentConfig from './pages/AdminPaymentConfig';
import AdminPaymentStats from './pages/AdminPaymentStats';
import AdminIndustries from './pages/AdminIndustries';
@@ -87,6 +88,7 @@ const App = () => {
<Route path="users" element={<AdminUsers />} />
<Route path="teams" element={<AdminTeams />} />
<Route path="credit-records" element={<AdminCreditRecords />} />
<Route path="model-pricing" element={<AdminModelPricingRules />} />
<Route path="models" element={<AdminModels />} />
<Route path="credit-ratios" element={<AdminCreditRatios />} />
<Route path="video-engines" element={<AdminVideoEngines />} />
+43
View File
@@ -13,6 +13,7 @@ import type {
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
ModelPricingRule, ModelPricingRuleListResponse, ModelPricingRulePayload, ModelPricingPreviewResponse,
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
@@ -296,12 +297,54 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'source_module', filters?.sourceModule);
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
setMaybe(params, 'billing_scene', filters?.billingScene);
setMaybe(params, 'engine_provider', filters?.engineProvider);
setMaybe(params, 'engine_model_name', filters?.engineModelName);
setMaybe(params, 'pricing_version_code', filters?.pricingVersionCode);
setMaybe(params, 'provider_cost_status', filters?.providerCostStatus);
setMaybe(params, 'provider_cost_is_estimated', filters?.providerCostIsEstimated);
setMaybe(params, 'has_attachment', filters?.hasAttachment);
setMaybe(params, 'start_date', filters?.startDate);
setMaybe(params, 'end_date', filters?.endDate);
const q = params.toString() ? `?${params}` : '';
return api.get(`/admin/credit-records${q}`);
}
export async function getModelPricingRules(filters?: {
page?: number; pageSize?: number; provider?: string; modelName?: string; modelCategory?: string; publishStatus?: string;
}): Promise<ModelPricingRuleListResponse> {
const params = new URLSearchParams();
setMaybe(params, 'page', filters?.page);
setMaybe(params, 'page_size', filters?.pageSize);
setMaybe(params, 'provider', filters?.provider);
setMaybe(params, 'model_name', filters?.modelName);
setMaybe(params, 'model_category', filters?.modelCategory);
setMaybe(params, 'publish_status', filters?.publishStatus);
return api.get(`/admin/model-pricing/rules${params.toString() ? `?${params}` : ''}`);
}
export async function createModelPricingRule(payload: ModelPricingRulePayload): Promise<ModelPricingRule> {
return api.post('/admin/model-pricing/rules', payload);
}
export async function updateModelPricingRule(id: string, payload: Partial<ModelPricingRulePayload>): Promise<ModelPricingRule> {
return api.put(`/admin/model-pricing/rules/${id}`, payload);
}
export async function publishModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/publish`);
}
export async function disableModelPricingRule(id: string): Promise<ModelPricingRule> {
return api.post(`/admin/model-pricing/rules/${id}/disable`);
}
export async function previewModelPricing(payload: {
billing_mode: string; calculator_version: string; rule_json: Record<string, any>; usage: Record<string, any>; currency?: string;
}): Promise<ModelPricingPreviewResponse> {
return api.post('/admin/model-pricing/preview', payload);
}
export async function getIndustryConfigs(): Promise<any[]> {
return api.get('/admin/industry-configs');
}
@@ -0,0 +1,486 @@
import React, { useEffect } from 'react';
import { Alert, Button, DatePicker, Divider, Form, Input, InputNumber, Select, Space } from 'antd';
import dayjs from 'dayjs';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingRule,
ModelPricingRulePayload,
} from '../../types';
interface Props {
initial?: ModelPricingRule | null;
loading?: boolean;
onSubmit: (payload: ModelPricingRulePayload) => Promise<void> | void;
onCancel: () => void;
}
const modeOptions = [
{ value: 'text_token_tiered', label: '文本分档 Token 计价' },
{ value: 'image_per_output', label: '按输出图片数量计价' },
{ value: 'image_input_output_tiered', label: '输入图片 + 输出像素分档计价' },
{ value: 'video_token_rate', label: '视频像素 Token 计价' },
];
const calculatorByMode: Record<ModelPricingBillingMode, ModelPricingCalculatorVersion> = {
text_token_tiered: 'text_token_tiered_v1',
image_per_output: 'image_per_output_v1',
image_input_output_tiered: 'image_input_output_tiered_v1',
video_token_rate: 'video_pixel_token_v1',
};
const billByOptions = [
{ value: 'successful_output_count', label: '实际成功输出数' },
{ value: 'requested_output_count', label: '请求输出数(估算)' },
{ value: 'provider_billed_count', label: '供应商明确计费数' },
];
function splitList(value?: string): string[] {
return String(value || '').split(',').map(v => v.trim()).filter(Boolean);
}
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* 后端规则 JSON 统一使用 snake_case;同时兼容接口层已转换成 camelCase 的历史/当前数据。
* 返回值会移除已知 camelCase 别名,避免保存时同时存在两套字段。
*/
function normalizeRuleJson(
mode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (mode === 'text_token_tiered') {
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: (Array.isArray(source.tiers) ? source.tiers : []).map(item => normalizeTextTier(item || {})),
};
}
if (mode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (mode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
function dimensionRows(rule: Record<string, any>): Array<Record<string, any>> {
const rows: Array<Record<string, any>> = [];
Object.entries(rule.dimension_map || {}).forEach(([resolution, ratios]) => {
Object.entries((ratios || {}) as Record<string, any>).forEach(([aspectRatio, value]) => {
const item = value as Record<string, any>;
rows.push({ resolution, aspectRatio, width: item.width, height: item.height });
});
});
return rows;
}
function toFormPricing(mode: ModelPricingBillingMode, rule: Record<string, any>): Record<string, any> {
const normalized = normalizeRuleJson(mode, rule || {});
if (mode === 'text_token_tiered') {
return {
tiers: normalized.tiers || [],
cacheStorageRate: normalized.cache_storage_rate_per_million_token_hour ?? 0,
};
}
if (mode === 'image_per_output') {
return {
outputRate: normalized.output_rate ?? 0,
billBy: normalized.bill_by || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
freeInputImages: normalized.free_input_images ?? 1,
inputImageRate: normalized.input_image_rate ?? 0,
outputTiers: normalized.output_tiers || [],
billBy: normalized.bill_by || 'successful_output_count',
};
}
return {
tokenFormula: normalized.token_formula
|| '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
supportedResolutions: (normalized.supported_resolutions || []).join(','),
defaultFps: normalized.default_fps ?? 30,
dimensionRows: dimensionRows(normalized),
rates: (normalized.rates || []).map((r: any) => ({
...r,
resolutions: (r.resolutions || []).join(','),
inferenceModes: (r.inference_modes || []).join(','),
hasInputVideo: r.has_input_video === undefined ? 'any' : r.has_input_video ? 'true' : 'false',
generateAudio: r.generate_audio === undefined ? 'any' : r.generate_audio ? 'true' : 'false',
})),
};
}
function buildDimensionMap(rows: Array<Record<string, any>>): Record<string, any> {
const result: Record<string, any> = {};
(rows || []).forEach(row => {
const resolution = String(row.resolution || '').trim().toLowerCase();
const aspectRatio = String(row.aspectRatio || '').trim();
const width = Number(row.width || 0);
const height = Number(row.height || 0);
if (!resolution || !aspectRatio || width <= 0 || height <= 0) return;
result[resolution] ||= {};
result[resolution][aspectRatio] = { width, height };
});
return result;
}
function buildRuleJson(
mode: ModelPricingBillingMode,
pricing: Record<string, any>,
baseRule: Record<string, any>,
): Record<string, any> {
const base = normalizeRuleJson(mode, baseRule || {});
if (mode === 'text_token_tiered') {
return {
...base,
unit: 'CNY_per_million_tokens',
cache_storage_rate_per_million_token_hour: String(pricing.cacheStorageRate ?? 0),
tiers: (pricing.tiers || []).map((v: any) => ({
max_context_tokens: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === '' || readAlias(v || {}, 'max_context_tokens', 'maxContextTokens') === undefined
? null
: readAlias(v || {}, 'max_context_tokens', 'maxContextTokens'),
input_rate: String(readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
audio_input_rate: String(readAlias(v || {}, 'audio_input_rate', 'audioInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
output_rate: String(readAlias(v || {}, 'output_rate', 'outputRate') ?? 0),
cached_input_rate: String(readAlias(v || {}, 'cached_input_rate', 'cachedInputRate') ?? readAlias(v || {}, 'input_rate', 'inputRate') ?? 0),
cached_audio_input_rate: String(
readAlias(v || {}, 'cached_audio_input_rate', 'cachedAudioInputRate')
?? readAlias(v || {}, 'cached_input_rate', 'cachedInputRate')
?? readAlias(v || {}, 'audio_input_rate', 'audioInputRate')
?? readAlias(v || {}, 'input_rate', 'inputRate')
?? 0,
),
})),
};
}
if (mode === 'image_per_output') {
return {
...base,
unit: 'CNY_per_image',
output_rate: String(pricing.outputRate ?? 0),
bill_by: pricing.billBy || 'successful_output_count',
};
}
if (mode === 'image_input_output_tiered') {
return {
...base,
unit: 'CNY_per_image',
free_input_images: Number(pricing.freeInputImages || 0),
input_image_rate: String(pricing.inputImageRate ?? 0),
output_tiers: (pricing.outputTiers || []).map((v: any) => ({
max_pixels: readAlias(v || {}, 'max_pixels', 'maxPixels') === undefined
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === null
|| readAlias(v || {}, 'max_pixels', 'maxPixels') === ''
? null
: Number(readAlias(v || {}, 'max_pixels', 'maxPixels')),
rate: String(v.rate ?? 0),
})),
bill_by: pricing.billBy || 'successful_output_count',
};
}
return {
...base,
unit: 'CNY_per_million_tokens',
token_formula: '(input_video_seconds + output_video_seconds) * width * height * fps / 1024',
default_fps: Number(pricing.defaultFps || 30),
supported_resolutions: splitList(pricing.supportedResolutions).map(v => v.toLowerCase()),
dimension_map: buildDimensionMap(pricing.dimensionRows || []),
rates: (pricing.rates || []).map((v: any) => ({
...(splitList(v.resolutions).length
? { resolutions: splitList(v.resolutions).map(item => item.toLowerCase()) }
: {}),
...(v.hasInputVideo !== 'any' && v.hasInputVideo !== undefined
? { has_input_video: v.hasInputVideo === 'true' }
: {}),
...(v.generateAudio !== 'any' && v.generateAudio !== undefined
? { generate_audio: v.generateAudio === 'true' }
: {}),
...(splitList(v.inferenceModes).length
? { inference_modes: splitList(v.inferenceModes).map(item => item.toLowerCase()) }
: {}),
rate: String(v.rate ?? 0),
})),
};
}
const PricingRuleForm: React.FC<Props> = ({ initial, loading, onSubmit, onCancel }) => {
const [form] = Form.useForm();
const mode = Form.useWatch('billingMode', form) as ModelPricingBillingMode | undefined;
useEffect(() => {
const billingMode = initial?.billingMode || 'video_token_rate';
form.setFieldsValue({
provider: initial?.provider || 'volcengine',
modelName: initial?.modelName || '',
modelCategory: initial?.modelCategory || 'video',
billingMode,
calculatorVersion: initial?.calculatorVersion || calculatorByMode[billingMode],
versionCode: initial?.versionCode || `manual_${dayjs().format('YYYYMMDD_HHmmss')}`,
effectiveRange: [
dayjs(initial?.effectiveFrom || undefined),
initial?.effectiveTo ? dayjs(initial.effectiveTo) : null,
],
currency: initial?.currency || 'CNY',
ruleSchemaVersion: initial?.ruleSchemaVersion || 1,
sourceUrl: initial?.sourceUrl || 'https://www.volcengine.com/docs/82379/1544106',
sourceUpdatedAt: initial?.sourceUpdatedAt ? dayjs(initial.sourceUpdatedAt) : null,
remark: initial?.remark || '',
pricing: toFormPricing(billingMode, initial?.ruleJson || {}),
});
}, [initial, form]);
useEffect(() => {
if (!mode) return;
form.setFieldValue('calculatorVersion', calculatorByMode[mode]);
}, [mode, form]);
const submit = async (values: any) => {
const billingMode = values.billingMode as ModelPricingBillingMode;
const [from, to] = values.effectiveRange || [];
const preserveBase = initial?.billingMode === billingMode ? initial.ruleJson : {};
await onSubmit({
provider: values.provider,
model_name: values.modelName,
model_category: values.modelCategory,
billing_mode: billingMode,
calculator_version: calculatorByMode[billingMode],
version_code: values.versionCode,
effective_from: from.toISOString(),
effective_to: to ? to.toISOString() : null,
currency: values.currency || 'CNY',
rule_schema_version: Number(values.ruleSchemaVersion || initial?.ruleSchemaVersion || 1),
rule_json: buildRuleJson(billingMode, values.pricing || {}, preserveBase || {}),
source_url: values.sourceUrl || null,
source_updated_at: values.sourceUpdatedAt ? values.sourceUpdatedAt.toISOString() : null,
remark: values.remark || null,
});
};
return (
<Form form={form} layout="vertical" onFinish={submit} preserve={false}>
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="种子规则只会创建草稿。请核对真实生效时间、单价和模型输出尺寸后再发布。"
/>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="provider" label="供应商" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="modelName" label="完整模型名称" rules={[{ required: true }]} style={{ flex: 2 }}><Input /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="modelCategory" label="模型类型" rules={[{ required: true }]} style={{ flex: 1 }}>
<Select options={[{ value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
</Form.Item>
<Form.Item name="billingMode" label="计价模式" rules={[{ required: true }]} style={{ flex: 2 }}>
<Select options={modeOptions} />
</Form.Item>
<Form.Item name="calculatorVersion" label="计算器版本" style={{ flex: 2 }}><Input disabled /></Form.Item>
</Space>
<Space size={12} align="start" style={{ display: 'flex' }}>
<Form.Item name="versionCode" label="价格版本号" rules={[{ required: true }]} style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name="effectiveRange" label="真实生效区间 [开始, 结束)" rules={[{ required: true }]} style={{ flex: 2 }}>
<DatePicker.RangePicker showTime allowEmpty={[false, true]} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="currency" label="币种" style={{ width: 100 }}><Input /></Form.Item>
<Form.Item name="ruleSchemaVersion" label="结构版本" style={{ width: 100 }}><InputNumber min={1} /></Form.Item>
</Space>
<Divider titlePlacement="start"></Divider>
{mode === 'text_token_tiered' && <>
<Form.Item name={['pricing', 'cacheStorageRate']} label="缓存存储单价(元/M Token·小时)">
<InputNumber min={0} stringMode style={{ width: 260 }} />
</Form.Item>
<Form.List name={['pricing', 'tiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => (
<Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'max_context_tokens']} label="最大上下文 Token"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'input_rate']} label="输入单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'audio_input_rate']} label="音频输入/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_input_rate']} label="缓存文本/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'cached_audio_input_rate']} label="缓存音频/M"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item {...field} name={[field.name, 'output_rate']} label="输出单价/M"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
))}
<Button onClick={() => add({})}> Token </Button>
</>}
</Form.List>
</>}
{mode === 'image_per_output' && <Space align="baseline">
<Form.Item name={['pricing', 'outputRate']} label="输出图片单价(元/张)" rules={[{ required: true }]}>
<InputNumber min={0} stringMode style={{ width: 220 }} />
</Form.Item>
<Form.Item name={['pricing', 'billBy']} label="计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>}
{mode === 'image_input_output_tiered' && <>
<Space align="baseline">
<Form.Item name={['pricing', 'freeInputImages']} label="免费输入图片数"><InputNumber min={0} /></Form.Item>
<Form.Item name={['pricing', 'inputImageRate']} label="超出后输入图片单价"><InputNumber min={0} stringMode /></Form.Item>
<Form.Item name={['pricing', 'billBy']} label="输出计费数量来源"><Select style={{ width: 220 }} options={billByOptions} /></Form.Item>
</Space>
<Form.List name={['pricing', 'outputTiers']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline">
<Form.Item {...field} name={[field.name, 'max_pixels']} label="最大像素(末档留空)"><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="输出单价(元/张)"><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({ max_pixels: null, rate: 0 })}></Button>
</>}
</Form.List>
</>}
{mode === 'video_token_rate' && <>
<Form.Item name={['pricing', 'tokenFormula']} label="Token 公式说明(由计算器版本实现,不执行文本公式)"><Input disabled /></Form.Item>
<Space align="baseline" wrap>
<Form.Item name={['pricing', 'supportedResolutions']} label="支持分辨率(逗号分隔)"><Input placeholder="480p,720p" /></Form.Item>
<Form.Item name={['pricing', 'defaultFps']} label="默认 FPS"><InputNumber min={1} max={120} /></Form.Item>
</Space>
<Divider titlePlacement="start" plain> + </Divider>
<Form.List name={['pricing', 'dimensionRows']}>
{(fields, { add, remove }) => <>
{fields.map(field => <Space key={field.key} align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolution']} label="分辨率" rules={[{ required: true }]}><Input placeholder="720p" /></Form.Item>
<Form.Item {...field} name={[field.name, 'aspectRatio']} label="比例" rules={[{ required: true }]}><Input placeholder="9:16" /></Form.Item>
<Form.Item {...field} name={[field.name, 'width']} label="宽" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item {...field} name={[field.name, 'height']} label="高" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>)}
<Button onClick={() => add({})}></Button>
</>}
</Form.List>
<Divider titlePlacement="start" plain></Divider>
<Form.List name={['pricing', 'rates']}>
{(fields, { add, remove }) => <>
{fields.map(field => <div key={field.key} style={{ border: '1px solid #eee', padding: 12, marginBottom: 12, borderRadius: 8 }}>
<Space align="baseline" wrap>
<Form.Item {...field} name={[field.name, 'resolutions']} label="分辨率"><Input placeholder="480p,720p;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'hasInputVideo']} label="含输入视频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'generateAudio']} label="生成音频"><Select style={{ width: 120 }} options={[{ value: 'any', label: '不限' }, { value: 'true', label: '是' }, { value: 'false', label: '否' }]} /></Form.Item>
<Form.Item {...field} name={[field.name, 'inferenceModes']} label="推理模式"><Input placeholder="online,flex;可空" /></Form.Item>
<Form.Item {...field} name={[field.name, 'rate']} label="单价/M Token" rules={[{ required: true }]}><InputNumber min={0} stringMode /></Form.Item>
<Button danger onClick={() => remove(field.name)}></Button>
</Space>
</div>)}
<Button onClick={() => add({ hasInputVideo: 'any', generateAudio: 'any' })}></Button>
</>}
</Form.List>
</>}
<Divider titlePlacement="start"></Divider>
<Form.Item name="sourceUrl" label="官方来源 URL"><Input /></Form.Item>
<Form.Item name="sourceUpdatedAt" label="官方文档更新时间(不是价格生效时间)"><DatePicker showTime /></Form.Item>
<Form.Item name="remark" label="备注"><Input.TextArea rows={3} /></Form.Item>
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button onClick={onCancel}></Button>
<Button type="primary" htmlType="submit" loading={loading}>稿</Button>
</Space>
</Form>
);
};
export default PricingRuleForm;
@@ -0,0 +1,229 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Input, message, Space, Typography } from 'antd';
import { previewModelPricing } from '../../api';
import type {
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingPreviewResponse,
} from '../../types';
interface Props {
billingMode: ModelPricingBillingMode;
calculatorVersion: ModelPricingCalculatorVersion;
ruleJson: Record<string, any>;
}
const examples: Record<ModelPricingBillingMode, Record<string, any>> = {
text_token_tiered: {
input_tokens: 1000,
output_tokens: 300,
cached_input_tokens: 0,
audio_input_tokens: 0,
usage_source: 'provider',
},
image_per_output: {
requested_output_count: 1,
successful_output_count: 1,
provider_billed_count: 1,
usage_source: 'provider_response',
},
image_input_output_tiered: {
provider_input_image_count: 2,
requested_output_count: 1,
successful_output_count: 1,
output_items: [{ width: 2048, height: 2048, pixels: 4194304 }],
usage_source: 'provider_response',
},
video_token_rate: {
total_tokens: 1000000,
resolution: '720p',
aspect_ratio: '9:16',
has_input_video: false,
generate_audio: false,
inference_mode: 'online',
usage_source: 'provider',
},
};
function readAlias(source: Record<string, any>, snakeKey: string, camelKey: string): any {
if (Object.prototype.hasOwnProperty.call(source, snakeKey)) return source[snakeKey];
return source[camelKey];
}
function removeKeys(source: Record<string, any>, keys: string[]): Record<string, any> {
const result = { ...source };
keys.forEach(key => delete result[key]);
return result;
}
function normalizeTextTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'max_context_tokens', 'maxContextTokens',
'input_rate', 'inputRate',
'audio_input_rate', 'audioInputRate',
'output_rate', 'outputRate',
'cached_input_rate', 'cachedInputRate',
'cached_audio_input_rate', 'cachedAudioInputRate',
]);
const maxContextTokens = readAlias(value || {}, 'max_context_tokens', 'maxContextTokens');
return {
...result,
max_context_tokens: maxContextTokens === undefined ? null : maxContextTokens,
input_rate: readAlias(value || {}, 'input_rate', 'inputRate'),
audio_input_rate: readAlias(value || {}, 'audio_input_rate', 'audioInputRate'),
output_rate: readAlias(value || {}, 'output_rate', 'outputRate'),
cached_input_rate: readAlias(value || {}, 'cached_input_rate', 'cachedInputRate'),
cached_audio_input_rate: readAlias(value || {}, 'cached_audio_input_rate', 'cachedAudioInputRate'),
};
}
function normalizeOutputTier(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, ['max_pixels', 'maxPixels']);
const maxPixels = readAlias(value || {}, 'max_pixels', 'maxPixels');
return {
...result,
max_pixels: maxPixels === undefined ? null : maxPixels,
rate: value?.rate,
};
}
function normalizeVideoRate(value: Record<string, any>): Record<string, any> {
const result = removeKeys(value || {}, [
'has_input_video', 'hasInputVideo',
'generate_audio', 'generateAudio',
'inference_modes', 'inferenceModes',
]);
const hasInputVideo = readAlias(value || {}, 'has_input_video', 'hasInputVideo');
const generateAudio = readAlias(value || {}, 'generate_audio', 'generateAudio');
const inferenceModes = readAlias(value || {}, 'inference_modes', 'inferenceModes');
return {
...result,
...(hasInputVideo === undefined ? {} : { has_input_video: hasInputVideo }),
...(generateAudio === undefined ? {} : { generate_audio: generateAudio }),
...(inferenceModes === undefined ? {} : { inference_modes: inferenceModes }),
};
}
/**
* API 返回层可能会把嵌套 rule_json 一并转成 camelCase。
* 后端计价器只接受 snake_case,因此试算前必须按计价模式恢复标准结构。
*/
function normalizeRuleJson(
billingMode: ModelPricingBillingMode,
value: Record<string, any>,
): Record<string, any> {
const source = value || {};
const base = removeKeys(source, [
'cache_storage_rate_per_million_token_hour', 'cacheStorageRatePerMillionTokenHour',
'output_rate', 'outputRate',
'bill_by', 'billBy',
'free_input_images', 'freeInputImages',
'input_image_rate', 'inputImageRate',
'output_tiers', 'outputTiers',
'token_formula', 'tokenFormula',
'default_fps', 'defaultFps',
'supported_resolutions', 'supportedResolutions',
'dimension_map', 'dimensionMap',
]);
if (billingMode === 'text_token_tiered') {
const tiers = Array.isArray(source.tiers) ? source.tiers : [];
return {
...base,
cache_storage_rate_per_million_token_hour: readAlias(
source,
'cache_storage_rate_per_million_token_hour',
'cacheStorageRatePerMillionTokenHour',
),
tiers: tiers.map(item => normalizeTextTier(item || {})),
};
}
if (billingMode === 'image_per_output') {
return {
...base,
output_rate: readAlias(source, 'output_rate', 'outputRate'),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
if (billingMode === 'image_input_output_tiered') {
const outputTiers = readAlias(source, 'output_tiers', 'outputTiers');
return {
...base,
free_input_images: readAlias(source, 'free_input_images', 'freeInputImages'),
input_image_rate: readAlias(source, 'input_image_rate', 'inputImageRate'),
output_tiers: (Array.isArray(outputTiers) ? outputTiers : []).map(item => normalizeOutputTier(item || {})),
bill_by: readAlias(source, 'bill_by', 'billBy'),
};
}
return {
...base,
token_formula: readAlias(source, 'token_formula', 'tokenFormula'),
default_fps: readAlias(source, 'default_fps', 'defaultFps'),
supported_resolutions: readAlias(source, 'supported_resolutions', 'supportedResolutions'),
dimension_map: readAlias(source, 'dimension_map', 'dimensionMap') || {},
rates: (Array.isArray(source.rates) ? source.rates : []).map(item => normalizeVideoRate(item || {})),
};
}
const PricingRulePreview: React.FC<Props> = ({ billingMode, calculatorVersion, ruleJson }) => {
const initial = useMemo(() => JSON.stringify(examples[billingMode], null, 2), [billingMode]);
const [usageText, setUsageText] = useState(initial);
const [result, setResult] = useState<ModelPricingPreviewResponse | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsageText(initial);
setResult(null);
}, [initial, ruleJson, calculatorVersion]);
const run = async () => {
setLoading(true);
try {
const usage = JSON.parse(usageText);
setResult(await previewModelPricing({
billing_mode: billingMode,
calculator_version: calculatorVersion,
rule_json: normalizeRuleJson(billingMode, ruleJson),
usage,
currency: 'CNY',
}));
} catch (e: any) {
message.error(e?.message || '计价试算失败');
} finally {
setLoading(false);
}
};
return <Card size="small" title="规则试算" style={{ marginTop: 16 }}>
<Typography.Text type="secondary">
{calculatorVersion}使使 Token
</Typography.Text>
<Input.TextArea
value={usageText}
onChange={e => setUsageText(e.target.value)}
rows={10}
style={{ marginTop: 10, fontFamily: 'monospace' }}
/>
<Space style={{ marginTop: 10 }}>
<Button type="primary" loading={loading} onClick={run}></Button>
</Space>
{result && <Alert
style={{ marginTop: 12 }}
type={result.isEstimated ? 'warning' : 'success'}
showIcon
message={`${result.currency} ${result.amount}${result.isEstimated ? '(估算)' : ''}`}
description={<>
<div style={{ marginBottom: 8 }}>{result.usageSource || '-'}</div>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{JSON.stringify(result.breakdown, null, 2)}</pre>
</>}
/>}
</Card>;
};
export default PricingRulePreview;
+226 -247
View File
@@ -1,12 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
Button, Card, DatePicker, Descriptions, Drawer, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, EyeOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
} from '@ant-design/icons';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import { formatDate } from '../utils/formatDate';
@@ -14,21 +14,15 @@ import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
totalRecharge: 0,
totalConsume: 0,
totalRefund: 0,
transactionCount: 0,
generationCount: 0,
generationAttemptCount: 0,
imageGenerationCount: 0,
videoGenerationCount: 0,
imageConsume: 0,
videoConsume: 0,
textConsume: 0,
analysisConsume: 0,
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
totalRecharge: 0, totalConsume: 0, totalRefund: 0, transactionCount: 0,
generationCount: 0, generationAttemptCount: 0, imageGenerationCount: 0, videoGenerationCount: 0,
imageConsume: 0, videoConsume: 0, textConsume: 0, analysisConsume: 0,
totalTokens: 0, inputTokens: 0, outputTokens: 0,
attachmentImageCount: 0, attachmentVideoCount: 0, attachmentAudioCount: 0, attachmentTotalCount: 0,
generatedImageCount: 0, generatedVideoCount: 0, generatedTotalCount: 0,
providerCostCalculatedTotal: '0.00000000', providerCostEstimatedTotal: '0.00000000',
providerCostCombinedTotal: '0.00000000', providerCostTotal: '0.00000000',
providerCostPendingCount: 0, providerCostEstimatedCount: 0, providerCostAbnormalCount: 0,
};
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
@@ -39,73 +33,38 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
};
const userScopeOptions = [
{ value: '', label: '全部用户' },
{ value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' },
{ value: 'frontend_external', label: '前台外部用户' },
{ value: '', label: '全部用户' }, { value: 'admin', label: '后台用户' },
{ value: 'frontend_internal', label: '前台内部用户' }, { value: 'frontend_external', label: '前台外部用户' },
];
const recordTypeOptions = [
{ value: '', label: '全部流水' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'team_internal', label: '团队内部' },
{ value: '', label: '全部流水' }, { value: 'recharge', label: '充值' }, { value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' }, { value: 'team_internal', label: '团队内部' },
];
const creditSubjectOptions = [
{ value: '', label: '全部积分类型' },
{ value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' },
{ value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' },
{ value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' },
{ value: 'unknown', label: '历史未知' },
{ value: '', label: '全部积分类型' }, { value: 'media', label: '图片/视频生成积分' },
{ value: 'text', label: '提词优化积分' }, { value: 'module', label: '模块功能积分' },
{ value: 'analysis', label: '分析积分' }, { value: 'split', label: '切片积分' },
{ value: 'admin_adjust', label: '管理员调整' }, { value: 'team_internal', label: '团队内部转移' },
{ value: 'recharge', label: '充值积分' }, { value: 'unknown', label: '历史未知' },
];
const mediaTypeOptions = [
{ value: '', label: '全部媒体' },
{ value: 'image', label: '图片' },
{ value: 'video', label: '视频' },
];
const mediaTypeOptions = [{ value: '', label: '全部媒体' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }];
const chargeKindOptions = [
{ value: '', label: '全部扣费子类' },
{ value: 'media', label: '媒体生成' },
{ value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' },
{ value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' },
{ value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' },
{ value: 'admin_adjust', label: '管理员调整' },
{ value: '', label: '全部扣费子类' }, { value: 'media', label: '媒体生成' }, { value: 'text_prompt', label: '提词优化' },
{ value: 'file_parse', label: '文件解析' }, { value: 'vision_input', label: '图片理解' },
{ value: 'module_create', label: '创建模块项目' }, { value: 'video_analysis', label: '视频分析' },
{ value: 'video_split', label: '视频切片' }, { value: 'admin_adjust', label: '管理员调整' },
{ value: 'team_internal', label: '团队内部转移' },
];
const sourceModuleOptions = [
{ value: '', label: '全部模块' },
{ value: 'ai_creation', label: 'AI创作' },
{ value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
{ value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' },
{ value: 'payment', label: '支付充值' },
{ value: 'team', label: '团队管理' },
{ value: 'unknown', label: '历史未知' },
{ value: '', label: '全部模块' }, { value: 'ai_creation', label: 'AI创作' }, { value: 'generation_record', label: '项目记录' },
{ value: 'hot_opening_replicate', label: '爆款开头复刻' }, { value: 'shot_replicate', label: '拆镜复刻' },
{ value: 'admin', label: '后台管理' }, { value: 'payment', label: '支付充值' }, { value: 'team', label: '团队管理' },
];
const sourceStepOptions = [
{ value: '', label: '全部步骤' },
{ value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' },
{ value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' },
{ value: 'video_analysis', label: '视频分析' },
{ value: '', label: '全部步骤' }, { value: 'image_prompt_optimize', label: '图片提词优化' },
{ value: 'image_generate', label: '图片生成' }, { value: 'video_prompt_optimize', label: '视频提词优化' },
{ value: 'video_generate', label: '视频生成' }, { value: 'video_analysis', label: '视频分析' },
];
const billingSceneOptions = [
{ value: '', label: '全部计费场景' },
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
@@ -135,24 +94,45 @@ const billingSceneOptions = [
{ value: 'team_internal_transfer', label: '团队内部转账' },
{ value: 'unknown', label: '历史未知' },
];
const costStatusOptions = [
{ value: '', label: '全部成本状态' }, { value: 'pending', label: '待回填' }, { value: 'calculated', label: '已核算' },
{ value: 'estimated', label: '估算' }, { value: 'unmatched_rule', label: '未匹配价格' },
{ value: 'usage_missing', label: '用量缺失' }, { value: 'historical_price_unavailable', label: '历史价格缺失' },
{ value: 'historical_engine_unavailable', label: '历史引擎缺失' },
{ value: 'provider_result_uncertain', label: '供应商结果不确定' },
{ value: 'not_incurred', label: '供应商费用未发生' },
{ value: 'error', label: '核算异常' }, { value: 'not_applicable', label: '不涉及成本' },
];
function n(value: number | undefined | null): string {
return Number(value || 0).toLocaleString();
function n(value: number | string | undefined | null, digits = 0): string {
const parsed = Number(value || 0);
return parsed.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function engineTypeLabel(type?: string): string {
if (type === 'model') return '提词/分析模型';
if (type === 'image') return '图片引擎';
if (type === 'video') return '视频引擎';
return '执行配置';
}
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
if (scope === 'admin') return { userType: 'admin' };
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
return {};
}
function costStatusColor(status?: string): string {
if (status === 'calculated') return 'green';
if (status === 'estimated') return 'orange';
if (status === 'pending') return 'blue';
if (status === 'not_applicable') return 'default';
return 'red';
}
const JsonBlock: React.FC<{ value?: Record<string, any> | null }> = ({ value }) => (
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto', whiteSpace: 'pre-wrap' }}>
{value ? JSON.stringify(value, null, 2) : '-'}
</pre>
);
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
@@ -163,6 +143,7 @@ const AdminCreditRecords: React.FC = () => {
const [exportProgress, setExportProgress] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [detail, setDetail] = useState<AdminCreditRecord | null>(null);
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
@@ -175,24 +156,24 @@ const AdminCreditRecords: React.FC = () => {
const [sourceStepCode, setSourceStepCode] = useState('');
const [billingScene, setBillingScene] = useState('');
const [userNameFilter, setUserNameFilter] = useState('');
const [engineProvider, setEngineProvider] = useState('');
const [engineModelName, setEngineModelName] = useState('');
const [pricingVersionCode, setPricingVersionCode] = useState('');
const [providerCostStatus, setProviderCostStatus] = useState('');
const [hasAttachment, setHasAttachment] = useState('');
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
page,
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined,
sourceModule: sourceModule || undefined,
sourceStepCode: sourceStepCode || undefined,
page, pageSize, userName: userNameFilter || undefined, teamId: teamFilter || undefined,
recordType: recordType || undefined, creditSubject: creditSubject || undefined, mediaType: mediaType || undefined,
chargeKind: chargeKind || undefined, sourceModule: sourceModule || undefined, sourceStepCode: sourceStepCode || undefined,
billingScene: billingScene || undefined,
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
engineProvider: engineProvider || undefined, engineModelName: engineModelName || undefined,
pricingVersionCode: pricingVersionCode || undefined, providerCostStatus: providerCostStatus || undefined,
hasAttachment: hasAttachment === '' ? undefined : hasAttachment === 'true',
startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, engineProvider, engineModelName, pricingVersionCode, providerCostStatus, hasAttachment, dateRange, userScope]);
const load = async () => {
setLoading(true);
@@ -200,33 +181,20 @@ const AdminCreditRecords: React.FC = () => {
const res = await getCreditRecords(query);
setRecords(res.items || []);
setTotal(res.total || 0);
setSummary(res.summary || DEFAULT_SUMMARY);
setSummary({ ...DEFAULT_SUMMARY, ...(res.summary || {}) });
} catch (e: any) {
message.error(e?.message || '加载积分记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
useEffect(() => {
getTeamOptions(true).then(setTeamOptions).catch(() => {});
}, []);
useEffect(() => { getTeamOptions(true).then(setTeamOptions).catch(() => {}); }, []);
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
setChargeKind('');
setSourceModule('');
setSourceStepCode('');
setBillingScene('');
setUserNameFilter('');
setDateRange([null, null]);
setPage(1);
setUserScope(''); setTeamFilter(''); setRecordType(''); setCreditSubject(''); setMediaType(''); setChargeKind('');
setSourceModule(''); setSourceStepCode(''); setBillingScene(''); setUserNameFilter(''); setEngineProvider(''); setEngineModelName('');
setPricingVersionCode(''); setProviderCostStatus(''); setHasAttachment(''); setDateRange([null, null]); setPage(1);
};
const exportExcel = async () => {
@@ -237,168 +205,179 @@ const AdminCreditRecords: React.FC = () => {
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
const first = await getCreditRecords(baseQuery);
const all: AdminCreditRecord[] = [...(first.items || [])];
const exportSummary = first.summary || DEFAULT_SUMMARY;
const exportSummary = { ...DEFAULT_SUMMARY, ...(first.summary || {}) };
const totalRows = first.total || 0;
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
for (let p = 2; p <= totalPages; p += 1) {
const res = await getCreditRecords({ ...baseQuery, page: p });
all.push(...(res.items || []));
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
}
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
const columns: StyledExcelColumn<AdminCreditRecord>[] = [
{ title: '时间', maxWidth: 22, render: r => formatDate(r.createdAt || '') },
{ title: '用户', maxWidth: 20, render: r => r.username || '-' }, { title: '手机号', maxWidth: 18, render: r => r.phone || '-' },
{ title: '用户类型', maxWidth: 16, render: r => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: r => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: r => r.teamNameSnapshot || '未分配团队' },
{ title: '流水类型', maxWidth: 14, render: r => r.recordTypeLabel || r.type }, { title: '积分类型', maxWidth: 20, render: r => r.creditSubjectLabel || '-' },
{ title: '扣费子类', maxWidth: 20, render: r => r.chargeKindLabel || '-' }, { title: '模块', maxWidth: 20, render: r => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: r => r.sourceStepCodeLabel || '-' }, { title: '计费场景', maxWidth: 32, render: r => r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: r => r.mediaTypeLabel || '-' },
{ title: '变动积分', numFmt: '#,##0.00', align: 'right', render: r => r.amount }, { title: '变动后余额', numFmt: '#,##0.00', align: 'right', render: r => r.balanceAfter },
{ title: '输入Token', numFmt: '#,##0', align: 'right', render: r => r.inputTokens || 0 }, { title: '输出Token', numFmt: '#,##0', align: 'right', render: r => r.outputTokens || 0 },
{ title: '实际Token', numFmt: '#,##0', align: 'right', render: r => r.totalTokens || 0 },
{ title: '图片附件数', numFmt: '#,##0', render: r => r.attachmentImageCount || 0 }, { title: '视频附件数', numFmt: '#,##0', render: r => r.attachmentVideoCount || 0 },
{ title: '音频附件数', numFmt: '#,##0', render: r => r.attachmentAudioCount || 0 }, { title: '附件总数', numFmt: '#,##0', render: r => r.attachmentTotalCount || 0 },
{ title: '输入视频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentVideoDurationSeconds || 0) },
{ title: '输入音频总时长(秒)', numFmt: '#,##0.000000', render: r => Number(r.attachmentAudioDurationSeconds || 0) },
{ title: '请求生成数', numFmt: '#,##0', render: r => r.requestedOutputCount || 0 }, { title: '实际生成图片数', numFmt: '#,##0', render: r => r.generatedImageCount || 0 },
{ title: '实际生成视频数', numFmt: '#,##0', render: r => r.generatedVideoCount || 0 }, { title: '实际生成总数', numFmt: '#,##0', render: r => r.generatedTotalCount || 0 },
{ title: '供应商', maxWidth: 18, render: r => r.engineProvider || '-' }, { title: '模型', maxWidth: 30, render: r => r.engineModelName || '-' },
{ title: '计价模式', maxWidth: 24, render: r => r.pricingBillingModeLabel || '-' }, { title: '计算器版本', maxWidth: 28, render: r => r.pricingCalculatorVersion || '-' },
{ title: '计价版本', maxWidth: 22, render: r => r.pricingVersionCode || '-' }, { title: '用量来源', maxWidth: 20, render: r => r.pricingUsageSource || '-' },
{ title: '计价时间', maxWidth: 22, render: r => formatDate(r.pricingReferenceAt || '') },
{ title: '供应商成本', numFmt: '#,##0.00000000', align: 'right', render: r => Number(r.providerCostAmount || 0) },
{ title: '成本币种', render: r => r.providerCostCurrency || 'CNY' }, { title: '成本状态', maxWidth: 18, render: r => r.providerCostStatusLabel || '-' },
{ title: '是否估算', render: r => r.providerCostIsEstimated ? '是' : '否' },
{ title: '最终核算时间', maxWidth: 22, render: r => formatDate(r.providerCostFinalizedAt || '') },
{ title: '主供应商用量', render: r => r.providerUsagePrimary ? '是' : '' },
{ title: '执行类型', maxWidth: 18, render: r => engineTypeLabel(r.engineType) },
{ title: '执行配置', maxWidth: 28, render: r => r.engineName || '-' },
{ title: '关联状态', maxWidth: 14, align: 'center', render: r => r.ownerDeleted ? '关联已删除' : '正常' },
{ title: '说明', maxWidth: 42, render: r => r.description || '' }, { title: '业务归属类型', maxWidth: 22, render: r => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 30, render: r => r.ownerId || '' }, { title: 'BizKey', maxWidth: 36, render: r => r.bizKey || '' },
];
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
exportStyledExcel<AdminCreditRecord>({
filename,
sheetName: '积分流水',
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
exportStyledExcel({
filename: `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`, sheetName: '积分流水', title: '积分流水与供应商成本核查',
metadataRows: [['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], ['导出条数', totalRows]],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费', exportSummary.totalConsume],
['总回退', exportSummary.totalRefund],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume],
['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume],
['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
],
columns: detailColumns,
rows: all,
['总充值', exportSummary.totalRecharge], ['总消费', exportSummary.totalConsume], ['总回退', exportSummary.totalRefund], ['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount], ['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分', exportSummary.imageConsume], ['视频消费积分', exportSummary.videoConsume],
['提词消费积分', exportSummary.textConsume], ['视频分析积分', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens], ['输入 Token', exportSummary.inputTokens], ['输出 Token', exportSummary.outputTokens],
['输入图片附件数', exportSummary.attachmentImageCount], ['输入视频附件数', exportSummary.attachmentVideoCount], ['输入音频附件数', exportSummary.attachmentAudioCount],
['实际生成图片数', exportSummary.generatedImageCount], ['实际生成视频数', exportSummary.generatedVideoCount],
['已核算供应商成本', Number(exportSummary.providerCostCalculatedTotal || 0)],
['估算供应商成本', Number(exportSummary.providerCostEstimatedTotal || 0)],
['成本参考合计', Number(exportSummary.providerCostCombinedTotal || 0)],
['待核算流水数', exportSummary.providerCostPendingCount],
['估算成本流水数', exportSummary.providerCostEstimatedCount], ['异常成本流水数', exportSummary.providerCostAbnormalCount],
], columns, rows: all,
});
message.success('Excel 已导出');
} catch (e: any) {
message.error(e?.message || '导出失败');
} finally {
setExporting(false);
setExportProgress('');
}
} finally { setExporting(false); setExportProgress(''); }
};
const columns = [
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <div><Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.frontendUserKindLabel || '-'}</div></div> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: '流水类型', dataIndex: 'recordType', width: 105, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string, r: AdminCreditRecord) => <div><Tag>{v || '-'}</Tag><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.chargeKindLabel || '-'}</div></div> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 135, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 230, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 85, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v, 2)}</Typography.Text> },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v, 2) },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '执行配置', key: 'engine', width: 240, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 105, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '时间', dataIndex: 'createdAt', width: 165, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
{ title: '附件', key: 'attachments', width: 155, render: (_: any, r: AdminCreditRecord) => <div> {n(r.attachmentImageCount)} / {n(r.attachmentVideoCount)} / {n(r.attachmentAudioCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.attachmentTotalCount)}</div></div> },
{ title: '生成产出', key: 'outputs', width: 150, render: (_: any, r: AdminCreditRecord) => <div> {n(r.generatedImageCount)} / {n(r.generatedVideoCount)}<div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.requestedOutputCount)} / {n(r.generatedTotalCount)}</div></div> },
{ title: '供应商实价', key: 'providerCost', width: 175, render: (_: any, r: AdminCreditRecord) => <div><Typography.Text strong>{r.providerCostCurrency || 'CNY'} {n(r.providerCostAmount, 8)}</Typography.Text><div><Tag color={costStatusColor(r.providerCostStatus)}>{r.providerCostStatusLabel || '-'}</Tag>{r.providerCostIsEstimated && <Tag color="orange"></Tag>}</div></div> },
{ title: '计价版本', key: 'pricing', width: 220, render: (_: any, r: AdminCreditRecord) => <div><div>{r.pricingVersionCode || '未锁价'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.pricingBillingModeLabel || '-'} / {r.pricingCalculatorVersion || '-'}</div></div> },
{ title: '操作', key: 'action', width: 90, fixed: 'right' as const, render: (_: any, r: AdminCreditRecord) => <Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}></Button> },
];
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select
value={teamFilter}
onChange={(v) => { setPage(1); setTeamFilter(v); }}
style={{ width: 170 }}
options={[
{ value: '', label: '全部团队' },
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2050 }}
/>
</Card>
return <div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund, 2)}</div></div></Space></Card>
<Card bordered={false}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
);
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">{n(summary.imageGenerationCount)} / {n(summary.imageConsume, 2)} </Card>
<Card size="small">{n(summary.videoGenerationCount)} / {n(summary.videoConsume, 2)} </Card>
<Card size="small">{n(summary.textConsume, 2)} </Card>
<Card size="small">{n(summary.analysisConsume, 2)} </Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small">//{n(summary.attachmentImageCount)} / {n(summary.attachmentVideoCount)} / {n(summary.attachmentAudioCount)}</Card>
<Card size="small">/{n(summary.generatedImageCount)} / {n(summary.generatedVideoCount)}</Card>
<Card size="small">¥{n(summary.providerCostCalculatedTotal, 8)}</Card>
<Card size="small">{n(summary.providerCostEstimatedCount)} / ¥{n(summary.providerCostEstimatedTotal, 8)}</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small"> Token{n(summary.totalTokens)}</Card>
<Card size="small">{n(summary.providerCostPendingCount)} </Card>
<Card size="small">{n(summary.providerCostAbnormalCount)} </Card>
<Card size="small">¥{n(summary.providerCostCombinedTotal, 8)}</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select value={userScope} onChange={v => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select value={teamFilter} onChange={v => { setPage(1); setTeamFilter(v); }} style={{ width: 170 }} options={[{ value: '', label: '全部团队' }, { value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' }, ...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name }))]} />
<Select value={recordType} onChange={v => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={v => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={v => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={v => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={v => { setPage(1); setSourceModule(v); }} style={{ width: 160 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={v => { setPage(1); setSourceStepCode(v); }} style={{ width: 160 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={v => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Select value={providerCostStatus} onChange={v => { setPage(1); setProviderCostStatus(v); }} style={{ width: 160 }} options={costStatusOptions} />
<Select value={hasAttachment} onChange={v => { setPage(1); setHasAttachment(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部附件' }, { value: 'true', label: '有附件' }, { value: 'false', label: '无附件' }]} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={e => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Input placeholder="供应商" value={engineProvider} onChange={e => { setPage(1); setEngineProvider(e.target.value); }} style={{ width: 130 }} allowClear />
<Input placeholder="模型名称" value={engineModelName} onChange={e => { setPage(1); setEngineModelName(e.target.value); }} style={{ width: 230 }} allowClear />
<Input placeholder="计价版本" value={pricingVersionCode} onChange={e => { setPage(1); setPricingVersionCode(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={dates => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} />
</Space>
<Space><Button onClick={handleReset}></Button><Button icon={<ReloadOutlined />} onClick={load}></Button><Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button></Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table columns={columns} dataSource={records} rowKey="id" loading={loading} scroll={{ x: 3650 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showTotal: t => `${t}` }} />
</Card>
<Drawer open={!!detail} width={860} title="积分流水财务核查" onClose={() => setDetail(null)}>
{detail && <>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="流水ID" span={2}>{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.username || '-'} / {detail.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="发生时间">{formatDate(detail.createdAt || '')}</Descriptions.Item>
<Descriptions.Item label="积分变动">{n(detail.amount, 2)}</Descriptions.Item>
<Descriptions.Item label="余额">{n(detail.balanceAfter, 2)}</Descriptions.Item>
<Descriptions.Item label="模型" span={2}>{detail.engineProvider || '-'} / {detail.engineModelName || detail.engineName || '-'}</Descriptions.Item>
<Descriptions.Item label="计价版本">{detail.pricingVersionCode || '-'}</Descriptions.Item>
<Descriptions.Item label="计价模式">{detail.pricingBillingModeLabel || '-'}</Descriptions.Item>
<Descriptions.Item label="计算器版本">{detail.pricingCalculatorVersion || '-'}</Descriptions.Item>
<Descriptions.Item label="用量来源">{detail.pricingUsageSource || '-'}</Descriptions.Item>
<Descriptions.Item label="计价参考时间">{formatDate(detail.pricingReferenceAt || '')}</Descriptions.Item>
<Descriptions.Item label="价格生效区间">{formatDate(detail.pricingEffectiveFrom || '')} {detail.pricingEffectiveTo ? formatDate(detail.pricingEffectiveTo) : '长期'}</Descriptions.Item>
<Descriptions.Item label="供应商成本"><b>{detail.providerCostCurrency || 'CNY'} {n(detail.providerCostAmount, 8)}</b></Descriptions.Item>
<Descriptions.Item label="核算状态"><Tag color={costStatusColor(detail.providerCostStatus)}>{detail.providerCostStatusLabel || '-'}</Tag></Descriptions.Item>
<Descriptions.Item label="最终核算时间">{formatDate(detail.providerCostFinalizedAt || '')}</Descriptions.Item>
<Descriptions.Item label="主供应商用量">{detail.providerUsagePrimary ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="附件统计"> {detail.attachmentImageCount} / {detail.attachmentVideoCount} / {detail.attachmentAudioCount}</Descriptions.Item>
<Descriptions.Item label="产出统计"> {detail.requestedOutputCount} / {detail.generatedImageCount} / {detail.generatedVideoCount}</Descriptions.Item>
<Descriptions.Item label="Token"> {detail.inputTokens} / {detail.outputTokens} / {detail.totalTokens}</Descriptions.Item>
<Descriptions.Item label="业务归属">{detail.ownerType || '-'} / {detail.ownerId || '-'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.pricingSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.usageSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.attachmentSnapshotJson} />
<Typography.Title level={5}></Typography.Title><JsonBlock value={detail.generationSnapshotJson} />
</>}
</Drawer>
</div>;
};
export default AdminCreditRecords;
@@ -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<string, { color: string; text: string }> = {
draft: { color: 'default', text: '草稿' },
published: { color: 'green', text: '已发布' },
disabled: { color: 'red', text: '已停用' },
};
const modeMap: Record<string, string> = {
text_token_tiered: '文本分档 Token',
image_per_output: '按成功输出图片',
image_input_output_tiered: '输入图 + 输出像素',
video_token_rate: '视频 Token',
};
const AdminModelPricingRules: React.FC = () => {
const [rows, setRows] = useState<ModelPricingRule[]>([]);
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<ModelPricingRule | null>(null);
const [formOpen, setFormOpen] = useState(false);
const [detail, setDetail] = useState<ModelPricingRule | null>(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) => <div><Typography.Text strong>{v}</Typography.Text><div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider} / {r.modelCategory}</div></div> },
{ title: '价格版本', dataIndex: 'versionCode', width: 180 },
{ title: '计价模式/计算器', key: 'calculator', width: 230, render: (_: any, r: ModelPricingRule) => <div>{modeMap[r.billingMode] || r.billingMode}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.calculatorVersion}</div></div> },
{ title: '生效时间', key: 'effective', width: 290, render: (_: any, r: ModelPricingRule) => <div>{formatDate(r.effectiveFrom)}<div style={{ color: '#94a3b8', fontSize: 12 }}> {r.effectiveTo ? formatDate(r.effectiveTo) : '长期有效'}</div></div> },
{ title: '状态', dataIndex: 'publishStatus', width: 100, render: (v: string) => <Tag color={(statusMap[v] || {}).color}>{(statusMap[v] || {}).text || v}</Tag> },
{ title: '规则Hash/引用', key: 'hash', width: 190, render: (_: any, r: ModelPricingRule) => <div>{r.ruleContentHash ? `${r.ruleContentHash.slice(0, 12)}` : '-'}<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.referencedCount || 0} </div></div> },
{ title: '来源更新时间', dataIndex: 'sourceUpdatedAt', width: 170, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '操作', key: 'action', width: 310, fixed: 'right' as const, render: (_: any, r: ModelPricingRule) => <Space>
<Button size="small" icon={<EyeOutlined />} onClick={() => setDetail(r)}>/</Button>
{r.publishStatus === 'draft' && <Button size="small" onClick={() => { setEditing(r); setFormOpen(true); }}></Button>}
<Button size="small" icon={<CopyOutlined />} onClick={() => cloneRule(r)}></Button>
{r.publishStatus === 'draft' && <Popconfirm title="发布后价格正文不可修改,确认发布?" onConfirm={async () => { await publishModelPricingRule(r.id); message.success('已发布'); load(); }}><Button size="small" type="primary"></Button></Popconfirm>}
{r.publishStatus === 'published' && <Popconfirm title="停用后不再匹配新消费,历史快照不受影响。确认?" onConfirm={async () => { await disableModelPricingRule(r.id); message.success('已停用'); load(); }}><Button size="small" danger></Button></Popconfirm>}
</Space> },
];
return <div>
<Card bordered={false} style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Space wrap>
<Input allowClear placeholder="模型名称" value={modelName} onChange={e => { setPage(1); setModelName(e.target.value); }} style={{ width: 260 }} />
<Select value={category} onChange={v => { setPage(1); setCategory(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部类型' }, { value: 'text', label: '文本' }, { value: 'image', label: '图片' }, { value: 'video', label: '视频' }]} />
<Select value={status} onChange={v => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={[{ value: '', label: '全部状态' }, { value: 'draft', label: '草稿' }, { value: 'published', label: '已发布' }, { value: 'disabled', label: '已停用' }]} />
</Space>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); setFormOpen(true); }}></Button>
</Space>
</div>
<Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1500 }} pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</Card>
<Modal open={formOpen} title={editing?.id ? '编辑价格草稿' : editing ? '克隆价格版本' : '新增价格版本'} width={1100} footer={null} destroyOnClose onCancel={() => { setFormOpen(false); setEditing(null); }}>
<PricingRuleForm initial={editing} loading={saving} onSubmit={submit} onCancel={() => { setFormOpen(false); setEditing(null); }} />
</Modal>
<Drawer open={!!detail} width={760} title={detail ? `${detail.modelName} / ${detail.versionCode}` : '计价详情'} onClose={() => setDetail(null)}>
{detail && <>
<Space wrap style={{ marginBottom: 12 }}><Tag>{detail.provider}</Tag><Tag>{detail.modelCategory}</Tag><Tag color="blue">{modeMap[detail.billingMode] || detail.billingMode}</Tag><Tag color={(statusMap[detail.publishStatus] || {}).color}>{(statusMap[detail.publishStatus] || {}).text}</Tag></Space>
<Typography.Paragraph>{formatDate(detail.effectiveFrom)} {detail.effectiveTo ? formatDate(detail.effectiveTo) : '长期有效'}<br />{detail.calculatorVersion}<br /> Hash{detail.ruleContentHash || '-'}</Typography.Paragraph>
<Typography.Paragraph>{detail.sourceUrl || '-'}<br />{detail.sourceUpdatedAt ? formatDate(detail.sourceUpdatedAt) : '-'}</Typography.Paragraph>
<pre style={{ background: '#f7f8fa', borderRadius: 8, padding: 12, overflow: 'auto' }}>{JSON.stringify(detail.ruleJson, null, 2)}</pre>
<PricingRulePreview billingMode={detail.billingMode} calculatorVersion={detail.calculatorVersion} ruleJson={detail.ruleJson} />
</>}
</Drawer>
</div>;
};
export default AdminModelPricingRules;
+119
View File
@@ -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<string, any> | null;
usageSnapshotJson?: Record<string, any> | null;
attachmentSnapshotJson?: Record<string, any> | null;
generationSnapshotJson?: Record<string, any> | 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<string, any>;
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<string, any>;
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<string, any>;
}
// ── 首页素材行业装修 ──────────────────────────────────────
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/modelpricing/pricingruleform.tsx","./src/components/modelpricing/pricingrulepreview.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodelpricingrules.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -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")
+2
View File
@@ -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)
@@ -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
+37 -3
View File
@@ -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
+60 -7
View File
@@ -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 (
@@ -338,6 +339,7 @@ async def optimize(
attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=token_usage,
media_references=record.media_references,
)
await deduct_credits(
db, current_user.id, text_credits,
@@ -431,6 +433,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 +443,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 +468,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 +490,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 +571,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 +624,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,
@@ -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
@@ -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()
@@ -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()
+2
View File
@@ -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。
+114
View File
@@ -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: "历史引擎缺失",
}
+2 -1
View File
@@ -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",
@@ -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)
+55 -8
View File
@@ -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)
@@ -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)
@@ -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)
+46
View File
@@ -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
@@ -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]
@@ -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}
@@ -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
@@ -78,6 +116,28 @@ def _normalize_frontend_kind(value: str | None) -> str:
return value or FrontendUserKind.EXTERNAL.value
_ATTACHMENT_META_FIELDS = (
"attachment_image_count",
"attachment_video_count",
"attachment_audio_count",
"attachment_total_count",
"attachment_video_duration_seconds",
"attachment_audio_duration_seconds",
)
def _apply_attachment_counts(meta: CreditRecordMeta, counts: Mapping[str, Any]) -> None:
"""只把 CreditRecord 实际存在的附件聚合字段平铺到元数据对象。
provider_input_* 属于供应商 usage 快照,不是 CreditRecordMeta/credit_records 顶层字段。
CreditRecordMeta 使用 slots=True,动态 setattr 会直接抛 AttributeError。
"""
for key in _ATTACHMENT_META_FIELDS:
value = counts.get(key)
if value is not None:
setattr(meta, key, value)
async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id: str) -> CreditRecordMeta:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
@@ -130,14 +190,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 +297,18 @@ 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,
provider_uses_media_references: bool | None = None,
) -> CreditRecordMeta:
media_type = (gen_type or "").lower().strip() or None
if source_module is None:
@@ -244,7 +331,76 @@ 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))
if provider_uses_media_references is None:
# GenerationRecord 的附件只参与前置提示词优化,媒体供应商调用明确不再携带;
# ChatGenerationTask/模块任务则会在创建供应商任务时携带附件。
provider_uses_media_references = owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=provider_uses_media_references,
)
_apply_attachment_counts(meta, attachment_counts)
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",
}
provider_input_image_count = _safe_int(attachment_counts.get("provider_input_image_count"))
provider_input_video_count = _safe_int(attachment_counts.get("provider_input_video_count"))
provider_input_audio_count = _safe_int(attachment_counts.get("provider_input_audio_count"))
provider_input_video_duration = float(
input_video_duration
if input_video_duration is not None
else attachment_counts["attachment_video_duration_seconds"] or 0
)
provider_input_audio_duration = float(attachment_counts["attachment_audio_duration_seconds"] or 0)
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": provider_input_video_duration,
"input_audio_duration_seconds": provider_input_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,
"input_image_count": provider_input_image_count,
"has_input_video": bool(provider_input_video_count or provider_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(
@@ -254,6 +410,7 @@ async def build_generation_record_prompt_meta(
attempt_no: int,
charge_kind: str,
usage: Mapping[str, Any],
media_references: Any = None,
) -> CreditRecordMeta:
scene_map = {
CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
@@ -274,7 +431,14 @@ 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)
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=True,
)
_apply_attachment_counts(meta, attachment_counts)
meta.attachment_snapshot_json = attachment_snapshot
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
async def build_module_step_prompt_meta(
@@ -305,7 +469,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 +502,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 +530,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),
)
@@ -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,
@@ -241,6 +241,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=usage,
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -270,6 +271,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -299,6 +301,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -457,8 +460,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 +478,9 @@ 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,
provider_uses_media_references: bool | None = None,
) -> BillingSummary:
"""图片/视频媒体生成扣费。
@@ -504,6 +515,18 @@ 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,
provider_uses_media_references=provider_uses_media_references,
)
if gen_type == "image":
@@ -561,6 +584,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 +593,6 @@ 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,
provider_uses_media_references=False,
)
@@ -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,
}
})
@@ -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)
@@ -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}"
@@ -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,
@@ -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(
+31 -6
View File
@@ -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(
@@ -167,10 +185,17 @@ def submit_image_task(
"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 = "图片生成超时,请稍后重试"
@@ -187,9 +212,9 @@ def submit_image_task(
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 ""),
}
+3 -2
View File
@@ -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
@@ -1,89 +1,68 @@
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_float,
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 +76,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 +96,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 +123,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 +133,190 @@ 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)
provider_uses_media_references = charge.owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
getattr(owner, "media_references", None),
allow_provider_input=provider_uses_media_references,
)
generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
owner,
provider_response=response,
stage=stage,
)
existing_usage = deepcopy(dict(charge.usage_snapshot_json or {}))
locked_input_image_count = safe_int(
existing_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
)
locked_input_video_count = safe_int(
existing_usage.get("provider_input_video_count"),
safe_int(attachment_counts.get("provider_input_video_count")),
)
locked_input_audio_count = safe_int(
existing_usage.get("provider_input_audio_count"),
safe_int(attachment_counts.get("provider_input_audio_count")),
)
locked_input_video_duration = safe_float(
existing_usage.get("input_video_duration_seconds"),
safe_float(attachment_counts.get("attachment_video_duration_seconds")),
)
locked_input_audio_duration = safe_float(
existing_usage.get("input_audio_duration_seconds"),
safe_float(attachment_counts.get("attachment_audio_duration_seconds")),
)
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=locked_input_image_count,
)
usage = {**existing_usage, **generation_usage, **provider_usage}
provider_input_image_count = safe_int(
provider_usage.get("provider_input_image_count"),
locked_input_image_count,
)
provider_input_video_count = safe_int(
provider_usage.get("provider_input_video_count"),
locked_input_video_count,
)
provider_input_audio_count = safe_int(
provider_usage.get("provider_input_audio_count"),
locked_input_audio_count,
)
input_video_duration_seconds = safe_float(
provider_usage.get("input_video_duration_seconds"),
locked_input_video_duration,
)
input_audio_duration_seconds = safe_float(
provider_usage.get("input_audio_duration_seconds"),
locked_input_audio_duration,
)
usage.update(
{
"has_input_video": bool(provider_input_video_count or input_video_duration_seconds),
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
"input_image_count": provider_input_image_count,
"input_video_duration_seconds": input_video_duration_seconds,
"input_audio_duration_seconds": input_audio_duration_seconds,
"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 +325,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 +375,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,
)
@@ -0,0 +1,7 @@
"""模型计价服务软包。
各调用方显式导入 calculator/rule_service/snapshot_service避免导入纯计算器时
提前初始化数据库引擎降低模块耦合并便于离线测试
"""
__all__: list[str] = []
@@ -0,0 +1,339 @@
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,
*,
allow_provider_input: bool,
) -> bool:
# 是否作为供应商直接输入由服务端调用链决定,不能信任客户端附件字段。
if not allow_provider_input:
return False
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,
*,
allow_provider_input: bool = True,
) -> 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,
allow_provider_input=allow_provider_input,
)
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
@@ -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}")
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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,
)
@@ -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"]
+106 -10
View File
@@ -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()
@@ -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,
+2
View File
@@ -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,
@@ -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,
@@ -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
@@ -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)
@@ -44,7 +44,7 @@ const CreditRecordsPage: React.FC = () => {
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,0.1)' },
team_internal: { color: '#0958d9', label: '团队内部', bg: 'rgba(139,92,246,0.1)' },
team_internal: { color: '#0958d9', label: '团队内部', bg: 'rgba(9, 88, 217, 0.1)' },
};
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
return (