diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx
index 312d65e2..322e9178 100644
--- a/video-gen-admin/src/pages/AdminCreditRecords.tsx
+++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx
@@ -35,6 +35,8 @@ const RECORD_TYPE_MAP: Record
},
consume: { text: '消费', color: 'red', icon:
},
refund: { text: '回退', color: 'blue', icon:
},
+ expire: { text: '过期', color: 'orange', icon:
},
+ revoke: { text: '撤销', color: 'volcano', icon:
},
team_internal: { text: '团队内部', color: 'cyan', icon:
},
};
@@ -58,6 +60,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
+ { value: 'expire', label: '过期' },
+ { value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -280,10 +284,13 @@ const AdminCreditRecords: React.FC = () => {
{ 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: 32, render: (r) => r.sceneNameSnapshot || 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.amount },
+ { title: '有效余额变动', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceDelta ?? r.amount },
+ { title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
+ { title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0})` },
{ 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 },
@@ -295,6 +302,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
+ { title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join(';') },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -345,10 +353,13 @@ const AdminCreditRecords: React.FC = () => {
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ?
{r.chargeActionLabel || cfg.text} :
历史; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) =>
{v || '-'} },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
- { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
},
+ { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.sceneNameSnapshot || r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
},
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ?
{v} : '-' },
- { title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) =>
0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)} },
+ { title: '业务积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) =>
{n(v)} },
+ { title: '有效变动', dataIndex: 'balanceDelta', width: 120, render: (v: number | undefined, r: AdminCreditRecord) => { const value = v ?? r.amount; return
0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}; } },
+ { title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ?
{n(v)} : '-' },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
+ { title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) =>
{n(r.llmCallCount || 0)} 次
成 {n(r.llmSuccessCallCount || 0)} / 败 {n(r.llmFailedCallCount || 0)}
},
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) =>
{n(r.totalTokens)}入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}
},
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) =>
{engineTypeLabel(r.engineType)}{r.engineName || '-'}
{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}
},
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) =>
{v ? '已删除' : '正常'} },
@@ -417,7 +428,27 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条记录`,
}}
- scroll={{ x: 2160 }}
+ expandable={{
+ rowExpandable: (record) => Boolean(record.allocations?.length),
+ expandedRowRender: (record) => (
+
v || '-' },
+ { title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
+ { title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
+ { title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
+ { title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
+ ]}
+ scroll={{ x: 1070 }}
+ />
+ ),
+ }}
+ scroll={{ x: 2520 }}
/>
diff --git a/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx
new file mode 100644
index 00000000..e715d8ef
--- /dev/null
+++ b/video-gen-admin/src/pages/AdminLlmBillingExecutions.tsx
@@ -0,0 +1,63 @@
+import React, { useEffect, useState } from 'react';
+import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
+import { HistoryOutlined } from '@ant-design/icons';
+import { getLlmBillingExecutions } from '../api';
+import type { LlmBillingExecution } from '../types';
+import { formatDate, formatDatePrecise } from '../utils/formatDate';
+
+const STATUS_LABELS: Record = {
+ pre_deducted: { label: '已预扣', color: 'blue' },
+ processing: { label: '处理中', color: 'processing' },
+ succeeded: { label: '业务成功', color: 'success' },
+ final_failed: { label: '最终失败', color: 'error' },
+ refunded: { label: '已退款', color: 'purple' },
+ refund_failed: { label: '退款失败', color: 'red' },
+};
+
+const AdminLlmBillingExecutions: React.FC = () => {
+ const [items, setItems] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [status, setStatus] = useState();
+ const [userId, setUserId] = useState('');
+ const [loading, setLoading] = useState(false);
+ const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
+ useEffect(() => { load(); }, [page, status]);
+ return LLM调用与积分审计} extra={ setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} />}>
+
+
({
+ color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
+ children:
+
{formatDatePrecise(call.requestStartedAt)} 第 {call.callSequence} 次调用:{call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}
+
完成时间:{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}
+
模型:{call.modelNameSnapshot || '-'} 耗时:{call.durationMs == null ? '-' : `${call.durationMs}ms`}
+ {call.tokenUnavailableReason
+ ?
Token:{call.tokenUnavailableReason}
+ :
输入 Token:{call.inputTokens ?? 0} 输出 Token:{call.outputTokens ?? 0} 总 Token:{call.totalTokens ?? 0}
}
+ {call.errorMessage &&
错误:{call.errorMessage}}
+ {call.postprocessStatus === 'succeeded' &&
后续处理:成功
}
+ {call.postprocessError &&
后续处理:失败,{call.postprocessError}
}
+
,
+ }))} />
+
+ }} columns={[
+ { title: '功能', dataIndex: 'sceneNameSnapshot' },
+ { title: '用户', dataIndex: 'userId' },
+ { title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
+ { title: '模型', dataIndex: 'modelNameSnapshot' },
+ { title: '预扣', dataIndex: 'preDeductCredits' },
+ { title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
+ { title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return {config.label}; } },
+ { title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
+ ]} />
+ ;
+};
+export default AdminLlmBillingExecutions;
diff --git a/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx
new file mode 100644
index 00000000..3342cf84
--- /dev/null
+++ b/video-gen-admin/src/pages/AdminLlmBillingPolicies.tsx
@@ -0,0 +1,51 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
+import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
+import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
+import type { LlmBillingPolicy } from '../types';
+
+const SCENES: Array<{ value: string; label: string }> = [
+ { value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
+ { value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
+ { value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
+ { value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
+ { value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
+ { value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
+ { value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
+];
+
+const AdminLlmBillingPolicies: React.FC = () => {
+ const [items, setItems] = useState([]);
+ const [editing, setEditing] = useState(null);
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [form] = Form.useForm();
+ const load = async () => { setLoading(true); try { setItems(await getLlmBillingPolicies()); } finally { setLoading(false); } };
+ useEffect(() => { load(); }, []);
+ const edit = (item?: LlmBillingPolicy) => { setEditing(item || null); setOpen(true); form.resetFields(); form.setFieldsValue(item ? { scene_code: item.sceneCode, pre_deduct_credits: item.preDeductCredits, is_active: item.isActive } : { is_active: true, pre_deduct_credits: 5 }); };
+ const save = async () => {
+ try {
+ const values = await form.validateFields();
+ if (editing) await updateLlmBillingPolicy(editing.id, values); else await createLlmBillingPolicy(values);
+ message.success('已保存'); setOpen(false); load();
+ } catch (error: any) { if (!error?.errorFields) message.error(error?.message || '保存失败'); }
+ };
+ return LLM固定预扣配置} extra={} onClick={() => edit()}>新增场景}>
+ {v ? '启用' : '停用'} },
+ { title: '操作', render: (_: unknown, r: LlmBillingPolicy) => } onClick={() => edit(r)}>编辑 },
+ ]} />
+ setOpen(false)}>
+
+
+
+
+
+ ;
+};
+export default AdminLlmBillingPolicies;
diff --git a/video-gen-admin/src/pages/AdminModels.tsx b/video-gen-admin/src/pages/AdminModels.tsx
index df711f1f..7be5989e 100644
--- a/video-gen-admin/src/pages/AdminModels.tsx
+++ b/video-gen-admin/src/pages/AdminModels.tsx
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
- mock: 'Mock模式',
};
- return {labelMap[v] || v};
+ return {labelMap[v] || v};
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
- 共 {models.length} 个模型配置,按权重进行加权随机调度
+ 共 {models.length} 个模型配置;任务创建时锁定模型,自动重试不会切换或降级
} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
= {
- normal: 'blue',
- gift: 'green',
- promo: 'purple',
-};
-
-const TYPE_LABELS: Record = {
- normal: '常规',
- gift: '赠送',
- promo: '促销',
-};
-
-const AdminRechargePackages: React.FC = () => {
- const [packages, setPackages] = useState([]);
- const [loading, setLoading] = useState(false);
- const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
- const [form] = Form.useForm();
-
- const load = async () => {
- setLoading(true);
- try {
- const data = await getRechargePackages();
- setPackages(data.map((item: any) => ({
- id: item.id,
- name: item.name,
- credits: item.credits,
- price: item.price,
- bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
- totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
- description: item.description,
- packageType: item.package_type ?? item.packageType ?? 'normal',
- isGift: item.is_gift ?? item.isGift ?? false,
- isActive: item.is_active ?? item.isActive ?? true,
- sortOrder: item.sort_order ?? item.sortOrder ?? 0,
- })));
- } catch {
- message.error('加载充值套餐失败');
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => { load(); }, []);
-
- const handleSave = async () => {
- try {
- const values = await form.validateFields();
- const payload = {
- name: values.name,
- credits: values.credits,
- price: values.price,
- bonus_credits: values.bonusCredits || 0,
- description: values.description || null,
- package_type: values.packageType || 'normal',
- is_gift: values.isGift || false,
- is_active: values.isActive ?? true,
- sort_order: values.sortOrder ?? 0,
- };
- if (modal.item?.id) {
- await saveRechargePackage({ id: modal.item.id, ...payload });
- message.success('已更新');
- } else {
- await saveRechargePackage(payload);
- message.success('已添加');
- }
- setModal({ open: false, item: null });
- form.resetFields();
- load();
- } catch { /* validation */ }
- };
-
- const handleDelete = async (id: string) => {
- try {
- await deleteRechargePackage(id);
- message.success('已删除');
- load();
- } catch (e: any) {
- message.error(e?.message || '删除失败');
- }
- };
-
- const openEdit = (item?: PackageItem) => {
- setModal({ open: true, item: item || null });
- if (item) {
- form.setFieldsValue({
- name: item.name,
- credits: item.credits,
- price: item.price,
- bonusCredits: item.bonusCredits,
- description: item.description,
- packageType: item.packageType,
- isGift: item.isGift,
- isActive: item.isActive,
- sortOrder: item.sortOrder,
- });
- } else {
- form.resetFields();
- form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
- }
- };
-
- const columns = [
- {
- title: '套餐名称', key: 'name', width: 160,
- render: (_: any, r: PackageItem) => (
-
-
{r.name}
- {r.description &&
{r.description}
}
-
- ),
- },
- {
- title: '基础积分', dataIndex: 'credits', width: 100,
- render: (v: number) => {v.toLocaleString()},
- },
- {
- title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
- render: (v: number) => v > 0
- ? +{v.toLocaleString()}
- : -,
- },
- {
- title: '总积分', key: 'total', width: 100,
- render: (_: any, r: PackageItem) => (
-
- {(r.credits + r.bonusCredits).toLocaleString()}
-
- ),
- },
- {
- title: '价格(元)', dataIndex: 'price', width: 100,
- render: (v: number) => ¥{v},
- },
- {
- title: '类型', dataIndex: 'packageType', width: 80,
- render: (v: string) => {TYPE_LABELS[v] || v},
- },
- {
- title: '状态', dataIndex: 'isActive', width: 80,
- render: (v: boolean) => {v ? '启用' : '停用'},
- },
- {
- title: '操作', key: 'action', width: 150, fixed: 'right' as const,
- render: (_: any, r: PackageItem) => (
-
- } onClick={() => openEdit(r)}>编辑
- handleDelete(r.id)}>
- }>删除
-
-
- ),
- },
- ];
-
- return (
-
-
-
-
-
- 充值套餐管理
- {packages.length} 个套餐
-
- } onClick={() => openEdit()} style={{ borderRadius: 8 }}>
- 添加套餐
-
-
-
-
-
-
-
{modal.item ? '编辑套餐' : '添加套餐'}}
- open={modal.open}
- onOk={handleSave}
- onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
- okText="保存" cancelText="取消" width={520}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default AdminRechargePackages;
+export { default } from './AdminCreditProducts';
diff --git a/video-gen-admin/src/pages/AdminSettings.tsx b/video-gen-admin/src/pages/AdminSettings.tsx
index fee2483a..fc84f816 100644
--- a/video-gen-admin/src/pages/AdminSettings.tsx
+++ b/video-gen-admin/src/pages/AdminSettings.tsx
@@ -48,14 +48,6 @@ const AdminSettings: React.FC = () => {
setConfigs(data);
const formValues: Record = {};
data.forEach(c => { formValues[c.key] = c.value; });
- // LLM 预扣积分默认值
- if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
- if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
- if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
- if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
- if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
- if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
- if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -70,80 +62,13 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
- const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
- String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
- );
- if (llmBillingEnabled) {
- const holdKeys = [
- 'optimize_hold_credits',
- 'llm_hold_credits_default',
- 'llm_hold_credits_generation_record_prompt',
- 'llm_hold_credits_module_image_prompt',
- 'llm_hold_credits_module_video_prompt',
- 'llm_hold_credits_shot_video_analysis',
- ];
- const invalidKey = holdKeys.find((key) => {
- const numericValue = Number(values[key]);
- return !Number.isFinite(numericValue) || numericValue <= 0;
- });
- if (invalidKey) {
- message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
- return;
- }
- }
setSaving(true);
- const llmManagedKeys = new Set([
- 'optimize_hold_credits',
- 'llm_billing_enabled',
- 'llm_hold_credits_default',
- 'llm_hold_credits_generation_record_prompt',
- 'llm_hold_credits_module_image_prompt',
- 'llm_hold_credits_module_video_prompt',
- 'llm_hold_credits_shot_video_analysis',
- ]);
for (const config of configs) {
- if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
- const saveManagedConfig = async (key: string, value: unknown, description: string) => {
- if (value === undefined || value === null || value === '') return;
- const normalizedValue = String(value);
- const existing = configs.find(c => c.key === key);
- if (existing) {
- if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
- } else {
- await createSystemConfig(key, normalizedValue, description);
- }
- };
-
- const enabledConfig = [
- 'llm_billing_enabled',
- values.llm_billing_enabled,
- '是否启用 LLM 统一预扣与真实扣费结算',
- ] as const;
- const llmHoldConfigs = [
- ['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
- ['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
- ['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
- ['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
- ['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
- ['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
- ] as const;
-
- // 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
- if (!llmBillingEnabled) {
- await saveManagedConfig(...enabledConfig);
- }
- for (const [key, value, description] of llmHoldConfigs) {
- await saveManagedConfig(key, value, description);
- }
- if (llmBillingEnabled) {
- await saveManagedConfig(...enabledConfig);
- }
-
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -254,7 +179,6 @@ const AdminSettings: React.FC = () => {
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => c.key === 'operation_manual'),
- 'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -270,13 +194,6 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
- optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
- llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
- llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
- llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
- llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
- llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
- llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
@@ -406,7 +323,7 @@ const AdminSettings: React.FC = () => {
);
}
- if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
+ if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return ;
}
return ;
@@ -467,26 +384,6 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
))}
- {/* AI创作预扣积分 - 固定显示 */}
- 兼容旧预扣积分数量}
- extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
- >
-
-
- {[
- ['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
- ['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
- ['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
- ['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
- ['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
- ['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
- ].map(([name, label, extra]) => (
- {label}} extra={extra}>
- {name === 'llm_billing_enabled' ? : }
-
- ))}
),
diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx
index 93be14ca..7b90ca40 100644
--- a/video-gen-admin/src/pages/AdminUsers.tsx
+++ b/video-gen-admin/src/pages/AdminUsers.tsx
@@ -6,12 +6,15 @@ import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons';
import {
- adjustCredits,
+ adminDeductCredits,
+ adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
deleteUserResourceCapacity,
getAdminUsers,
+ getAdminUserCreditBalances,
+ getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -66,6 +69,12 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState('');
const [teamOptions, setTeamOptions] = useState([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
+ const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
+ const [creditDetailLoading, setCreditDetailLoading] = useState(false);
+ const [creditSummary, setCreditSummary] = useState(null);
+ const [creditBalances, setCreditBalances] = useState([]);
+ const [creditBalanceStatus, setCreditBalanceStatus] = useState('');
+ const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -164,12 +173,44 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
- await adjustCredits(user.id, values.amount, values.description);
- message.success(`已${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
+ if (creditOperation === 'grant') {
+ await adminGrantCredits(user.id, {
+ amount: values.amount,
+ description: values.description,
+ validity_unit: values.validityUnit || 'month',
+ validity_value: values.validityValue || 1,
+ credit_level: 'general',
+ });
+ } else {
+ await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
+ }
+ message.success(`已${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
+ setCreditOperation('grant');
form.resetFields();
load();
- } catch { /* validation */ }
+ } catch (e: any) {
+ if (e?.errorFields) return;
+ message.error(e?.message || '积分操作失败');
+ }
+ };
+
+ const openCreditDetailModal = async (user: AdminUser, status = '') => {
+ setCreditDetailModal({ open: true, user });
+ setCreditBalanceStatus(status);
+ setCreditDetailLoading(true);
+ try {
+ const [summary, balances] = await Promise.all([
+ getAdminUserCreditSummary(user.id),
+ getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
+ ]);
+ setCreditSummary(summary);
+ setCreditBalances(balances || []);
+ } catch (e: any) {
+ message.error(e?.message || '加载用户积分明细失败');
+ } finally {
+ setCreditDetailLoading(false);
+ }
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -493,10 +534,20 @@ const AdminUsers: React.FC = () => {
{!isAdminTab && (
}
- onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
+ onClick={() => {
+ setCreditOperation('grant');
+ setCreditModal({ open: true, user: r });
+ form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
+ }}>
调整积分
)}
+ {!isAdminTab && (
+ }
+ onClick={() => openCreditDetailModal(r)}>
+ 积分明细
+
+ )}
{!isAdminTab && (
}
onClick={() => openCapacityModal(r)}>
@@ -685,50 +736,103 @@ const AdminUsers: React.FC = () => {
title={调整积分 - {creditModal.user?.username}}
open={creditModal.open}
onOk={handleAdjustCredits}
- onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
- okText="确认" cancelText="取消" width={440}
+ onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
+ okText="确认" cancelText="取消" width={480}
>
- 当前积分:
+ 当前有效积分:
{creditModal.user?.credits.toLocaleString()}
- {/* 快捷操作 */}
-
- 快捷操作
-
- } style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
- +1000 / 积分赠送
-
- } style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
- +500 / 积分赠送
-
- } style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
- -500 / 积分扣除
-
- } style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
- -1000 / 积分扣除
-
-
-
-
- `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
+
+
-
+
+
+
+ {creditOperation === 'grant' && (
+
+
+
+
+
+
+
+
+ )}
+
+ 积分明细 - {creditDetailModal.user?.username}}
+ open={creditDetailModal.open}
+ footer={null}
+ width={1180}
+ onCancel={() => {
+ setCreditDetailModal({ open: false, user: null });
+ setCreditSummary(null);
+ setCreditBalances([]);
+ setCreditBalanceStatus('');
+ }}
+ >
+
+
+
当前有效积分{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}
+
最近即将过期积分{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}
+
最近最后可用时间{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}
+
+
+ 积分批次
+
+ v || '-' },
+ { title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
+ { title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' },
+ { title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
+ { title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
+ { title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
+ { title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
+ { title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
+ { title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
+ { title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
+ { title: '状态', dataIndex: 'status', width: 90, render: (v: string) => {v} },
+ ]}
+ scroll={{ x: 1410, y: 460 }}
+ />
+
+
+
团队设置 - {teamModal.user?.username}}
open={teamModal.open}
@@ -893,7 +997,7 @@ const AdminUsers: React.FC = () => {
{createType === 'frontend' && (
-
+
)}
diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts
index 00a92680..68f8c452 100644
--- a/video-gen-admin/src/types/index.ts
+++ b/video-gen-admin/src/types/index.ts
@@ -884,6 +884,23 @@ export interface AdminCreditRecordSummary {
outputTokens: number;
}
+export interface AdminCreditRecordAllocation {
+ id: string;
+ creditBalanceId: string;
+ sourceAllocationId?: string | null;
+ allocationAction: string;
+ amount: number;
+ creditLevel?: string;
+ sourceType?: string;
+ sourceId?: string;
+ validFrom?: string;
+ expiresAt?: string;
+ unspentBefore?: number;
+ unspentAfter?: number;
+ consumedBefore?: number;
+ consumedAfter?: number;
+}
+
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -901,6 +918,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
+ balanceDelta?: number;
+ expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -920,6 +939,12 @@ export interface AdminCreditRecord {
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
+ sceneNameSnapshot?: string;
+ requestTime?: string;
+ llmCallCount?: number;
+ llmSuccessCallCount?: number;
+ llmFailedCallCount?: number;
+ allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -1426,3 +1451,94 @@ export interface VideoUpscaleConfigSavePayload {
}>;
};
}
+
+// ── Dynamic Credit Products / LLM Billing ─────────────────
+export type CreditProductType = 'subscription' | 'credit_addon';
+export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
+
+export interface CreditProduct {
+ id: string;
+ productCode: string;
+ productType: CreditProductType;
+ name: string;
+ description?: string | null;
+ features?: string[];
+ tierCode?: string | null;
+ tierRank?: number | null;
+ billingCycle?: SubscriptionBillingCycle | null;
+ monthlyGrantCredits?: number;
+ grantCount?: number;
+ firstPurchasePrice?: number;
+ regularPrice?: number;
+ activityPrice?: number | null;
+ activityStartAt?: string | null;
+ activityEndAt?: string | null;
+ renewalEnabled: boolean;
+ price: number;
+ grantCredits?: number;
+ validityMonths?: number | null;
+ creditLevel: 'promotional' | 'general';
+ currency: string;
+ isActive: boolean;
+ sortOrder: number;
+}
+
+export interface LlmBillingPolicy {
+ id: string;
+ sceneCode: string;
+ sceneName: string;
+ preDeductCredits: number;
+ isActive: boolean;
+ version: number;
+ createdBy?: string | null;
+ updatedBy?: string | null;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface LlmCallAttempt {
+ id: string;
+ callSequence: number;
+ retrySequence: number;
+ modelConfigId?: string | null;
+ modelNameSnapshot?: string | null;
+ providerSnapshot?: string | null;
+ providerRequestId?: string | null;
+ requestStartedAt: string;
+ responseReceivedAt?: string | null;
+ durationMs?: number | null;
+ status: string;
+ inputTokens?: number | null;
+ outputTokens?: number | null;
+ totalTokens?: number | null;
+ httpStatus?: number | null;
+ errorMessage?: string | null;
+ tokenUnavailableReason?: string | null;
+ postprocessStatus?: string | null;
+ postprocessError?: string | null;
+}
+
+export interface LlmBillingExecution {
+ id: string;
+ userId: string;
+ sceneCode: string;
+ sceneNameSnapshot: string;
+ ownerType: string;
+ ownerId: string;
+ businessAttemptNo: number;
+ modelNameSnapshot?: string | null;
+ providerSnapshot?: string | null;
+ requestTime: string;
+ preDeductCredits: number;
+ status: string;
+ totalCallCount: number;
+ successfulCallCount: number;
+ failedCallCount: number;
+ totalInputTokens: number;
+ totalOutputTokens: number;
+ totalTokens: number;
+ refundAvailableCredits: number;
+ refundExpiredCredits: number;
+ finalErrorMessage?: string | null;
+ calls: LlmCallAttempt[];
+}
diff --git a/video-gen-admin/src/utils/formatDate.ts b/video-gen-admin/src/utils/formatDate.ts
index 8fba6595..fe78471a 100644
--- a/video-gen-admin/src/utils/formatDate.ts
+++ b/video-gen-admin/src/utils/formatDate.ts
@@ -36,3 +36,23 @@ export function formatDate(iso: string | null | undefined): string {
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
+
+export function formatDatePrecise(iso: string | null | undefined): string {
+ if (!iso) return '-';
+ const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
+ const s = iso.trim();
+ const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
+ if (!m) return s.replace('T', ' ');
+ const [, year, month, day, hour, min, sec, tz] = m;
+ const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
+ let target = utcMs;
+ if (tz === 'Z') target += CST_OFFSET * 60000;
+ else if (tz) {
+ const sign = tz[0] === '+' ? 1 : -1;
+ const compact = tz.slice(1).replace(':', '');
+ const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
+ target = utcMs - offset * 60000 + CST_OFFSET * 60000;
+ }
+ const d = new Date(target);
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
+}
diff --git a/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py b/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py
new file mode 100644
index 00000000..9fa5a157
--- /dev/null
+++ b/video-gen-api/alembic/versions/7f3c8a2d9e41_add_credit_subscription_llm_billing.py
@@ -0,0 +1,1037 @@
+"""add dynamic credit ledger, subscriptions and llm billing
+
+Revision ID: 7f3c8a2d9e41
+Revises: 20da1d353914
+Create Date: 2026-08-06 15:43:00+08:00
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision: str = "7f3c8a2d9e41"
+down_revision: str | None = "20da1d353914"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ # 1. 统一积分产品:订阅套餐 / 积分增值包。
+ op.create_table(
+ "credit_products",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("product_code", sa.String(length=64), nullable=False),
+ sa.Column("product_type", sa.String(length=24), nullable=False),
+ sa.Column("name", sa.String(length=96), nullable=False),
+ sa.Column("description", sa.String(length=512), nullable=True),
+ sa.Column("features_json", sa.JSON(), nullable=True),
+ sa.Column("tier_code", sa.String(length=32), nullable=True),
+ sa.Column("tier_rank", sa.Integer(), nullable=True),
+ sa.Column("billing_cycle", sa.String(length=24), nullable=True),
+ sa.Column("monthly_grant_credits", sa.Numeric(precision=20, scale=2), nullable=True),
+ sa.Column("first_purchase_price", sa.Numeric(precision=20, scale=2), nullable=True),
+ sa.Column("regular_price", sa.Numeric(precision=20, scale=2), nullable=True),
+ sa.Column("activity_price", sa.Numeric(precision=20, scale=2), nullable=True),
+ sa.Column("activity_start_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("activity_end_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=True),
+ sa.Column("validity_months", sa.Integer(), nullable=True),
+ sa.Column(
+ "price",
+ sa.Numeric(precision=20, scale=2),
+ nullable=False,
+ server_default=sa.text("0"),
+ ),
+ sa.Column(
+ "credit_level",
+ sa.String(length=32),
+ nullable=False,
+ server_default=sa.text("'general'"),
+ ),
+ sa.Column(
+ "currency",
+ sa.String(length=8),
+ nullable=False,
+ server_default=sa.text("'CNY'"),
+ ),
+ sa.Column(
+ "is_active",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("true"),
+ ),
+ sa.Column(
+ "sort_order",
+ sa.Integer(),
+ nullable=False,
+ server_default=sa.text("0"),
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
+ sa.CheckConstraint(
+ "first_purchase_price IS NULL OR first_purchase_price >= 0",
+ name="ck_credit_products_first_price_nonnegative",
+ ),
+ sa.CheckConstraint(
+ "regular_price IS NULL OR regular_price >= 0",
+ name="ck_credit_products_regular_price_nonnegative",
+ ),
+ sa.CheckConstraint(
+ "activity_price IS NULL OR activity_price >= 0",
+ name="ck_credit_products_activity_price_nonnegative",
+ ),
+ sa.CheckConstraint(
+ "monthly_grant_credits IS NULL OR monthly_grant_credits > 0",
+ name="ck_credit_products_monthly_grant_positive",
+ ),
+ sa.CheckConstraint(
+ "grant_credits IS NULL OR grant_credits > 0",
+ name="ck_credit_products_grant_positive",
+ ),
+ sa.CheckConstraint(
+ "(product_type = 'subscription' AND tier_code IS NOT NULL "
+ "AND tier_rank IS NOT NULL AND billing_cycle IS NOT NULL "
+ "AND monthly_grant_credits IS NOT NULL "
+ "AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
+ "AND grant_credits IS NULL AND validity_months IS NULL) "
+ "OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
+ "AND validity_months = 1 AND tier_code IS NULL AND tier_rank IS NULL "
+ "AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
+ "AND first_purchase_price IS NULL AND regular_price IS NULL "
+ "AND activity_price IS NULL AND activity_start_at IS NULL "
+ "AND activity_end_at IS NULL)",
+ name="ck_credit_products_type_required_fields",
+ ),
+ sa.CheckConstraint(
+ "(activity_price IS NULL AND activity_start_at IS NULL "
+ "AND activity_end_at IS NULL) OR "
+ "(activity_price IS NOT NULL AND activity_start_at IS NOT NULL "
+ "AND activity_end_at IS NOT NULL "
+ "AND activity_end_at > activity_start_at)",
+ name="ck_credit_products_activity_window",
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("uq_credit_products_code", "credit_products", ["product_code"], unique=True)
+ op.create_index(
+ "ix_credit_products_public",
+ "credit_products",
+ ["product_type", "is_active", "sort_order"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_credit_products_product_type",
+ "credit_products",
+ ["product_type"],
+ unique=False,
+ )
+
+ # 2. 用户首充状态;删除旧单列余额。
+ op.add_column(
+ "users",
+ sa.Column("first_membership_paid_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index(
+ "ix_users_first_membership_paid_at",
+ "users",
+ ["first_membership_paid_at"],
+ unique=False,
+ )
+
+ # 3. 扩展支付订单产品、订阅及升级快照。
+ op.alter_column(
+ "payment_orders",
+ "amount",
+ existing_type=sa.Float(),
+ type_=sa.Numeric(precision=20, scale=2),
+ existing_nullable=False,
+ postgresql_using="amount::numeric(20,2)",
+ )
+ op.alter_column(
+ "payment_orders",
+ "credits",
+ existing_type=sa.Float(),
+ type_=sa.Numeric(precision=20, scale=2),
+ existing_nullable=False,
+ server_default=sa.text("0"),
+ postgresql_using="credits::numeric(20,2)",
+ )
+ op.alter_column(
+ "payment_orders",
+ "status",
+ existing_type=sa.String(length=16),
+ type_=sa.String(length=32),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "payment_orders",
+ "refund_amount",
+ existing_type=sa.Float(),
+ type_=sa.Numeric(precision=20, scale=2),
+ existing_nullable=True,
+ postgresql_using="refund_amount::numeric(20,2)",
+ )
+ op.add_column("payment_orders", sa.Column("product_id", sa.String(length=32), nullable=True))
+ op.add_column("payment_orders", sa.Column("product_type", sa.String(length=24), nullable=True))
+ op.add_column("payment_orders", sa.Column("purchase_scene", sa.String(length=24), nullable=True))
+ op.add_column("payment_orders", sa.Column("price_type", sa.String(length=24), nullable=True))
+ op.add_column("payment_orders", sa.Column("product_code_snapshot", sa.String(length=64), nullable=True))
+ op.add_column("payment_orders", sa.Column("product_name_snapshot", sa.String(length=96), nullable=True))
+ op.add_column("payment_orders", sa.Column("product_snapshot_json", sa.JSON(), nullable=True))
+ op.add_column("payment_orders", sa.Column("subscription_id", sa.String(length=32), nullable=True))
+ op.add_column("payment_orders", sa.Column("source_subscription_id", sa.String(length=32), nullable=True))
+ op.add_column("payment_orders", sa.Column("upgrade_period_ids_json", sa.JSON(), nullable=True))
+ op.add_column(
+ "payment_orders",
+ sa.Column("target_price_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
+ )
+ op.add_column(
+ "payment_orders",
+ sa.Column("deduction_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
+ )
+ op.add_column(
+ "payment_orders",
+ sa.Column("payable_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=True),
+ )
+ op.add_column("payment_orders", sa.Column("fulfillment_status", sa.String(length=32), nullable=True))
+ op.add_column("payment_orders", sa.Column("fulfilled_at", sa.DateTime(timezone=True), nullable=True))
+ op.create_foreign_key(
+ "fk_payment_orders_product_id_credit_products",
+ "payment_orders",
+ "credit_products",
+ ["product_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+ op.create_index("ix_payment_orders_product_id", "payment_orders", ["product_id"], unique=False)
+ op.create_index("ix_payment_orders_product_type", "payment_orders", ["product_type"], unique=False)
+ op.create_index("ix_payment_orders_subscription_id", "payment_orders", ["subscription_id"], unique=False)
+ op.create_index(
+ "ix_payment_orders_source_subscription_id",
+ "payment_orders",
+ ["source_subscription_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_payment_orders_fulfillment_status",
+ "payment_orders",
+ ["fulfillment_status"],
+ unique=False,
+ )
+
+ # 4. 用户订阅。
+ op.create_table(
+ "user_credit_subscriptions",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("user_id", sa.String(length=32), nullable=False),
+ sa.Column("product_id", sa.String(length=32), nullable=True),
+ sa.Column("payment_order_id", sa.String(length=32), nullable=False),
+ sa.Column(
+ "status",
+ sa.String(length=32),
+ nullable=False,
+ server_default=sa.text("'pending'"),
+ ),
+ sa.Column("purchase_scene", sa.String(length=24), nullable=False),
+ sa.Column("tier_code", sa.String(length=32), nullable=False),
+ sa.Column("tier_rank", sa.Integer(), nullable=False),
+ sa.Column("billing_cycle", sa.String(length=24), nullable=False),
+ sa.Column("anchor_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("start_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("next_grant_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("monthly_grant_credits_snapshot", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("grant_count", sa.Integer(), nullable=False),
+ sa.Column("granted_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("paid_amount_snapshot", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("product_snapshot_json", sa.JSON(), nullable=False),
+ sa.Column("source_subscription_id", sa.String(length=32), nullable=True),
+ sa.Column("upgrade_order_id", sa.String(length=32), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="RESTRICT"),
+ sa.ForeignKeyConstraint(
+ ["source_subscription_id"],
+ ["user_credit_subscriptions.id"],
+ ondelete="SET NULL",
+ ),
+ sa.ForeignKeyConstraint(["upgrade_order_id"], ["payment_orders.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "ix_user_credit_subscriptions_current",
+ "user_credit_subscriptions",
+ ["user_id", "status", "expires_at"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_credit_subscriptions_expire_due",
+ "user_credit_subscriptions",
+ ["status", "expires_at", "id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_credit_subscriptions_grant_due",
+ "user_credit_subscriptions",
+ ["status", "next_grant_at", "id"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_user_credit_subscriptions_payment",
+ "user_credit_subscriptions",
+ ["payment_order_id"],
+ unique=True,
+ )
+ op.create_index(
+ "ix_user_credit_subscriptions_user_id",
+ "user_credit_subscriptions",
+ ["user_id"],
+ unique=False,
+ )
+
+ # 5. 用户动态积分余额。
+ op.create_table(
+ "user_credit_balances",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("user_id", sa.String(length=32), nullable=False),
+ sa.Column("credit_level", sa.String(length=32), nullable=False),
+ sa.Column("credit_level_rank", sa.Integer(), nullable=False, server_default=sa.text("20")),
+ sa.Column("source_type", sa.String(length=48), nullable=False),
+ sa.Column("source_id", sa.String(length=64), nullable=True),
+ sa.Column("product_id", sa.String(length=32), nullable=True),
+ sa.Column("payment_order_id", sa.String(length=32), nullable=True),
+ sa.Column("subscription_id", sa.String(length=32), nullable=True),
+ # 外键在周期表创建后补上,解决双向依赖。
+ sa.Column("subscription_period_id", sa.String(length=32), nullable=True),
+ sa.Column("grant_record_id", sa.String(length=32), nullable=True),
+ sa.Column("grant_amount", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("unspent_amount", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("consumed_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
+ sa.Column("expired_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
+ sa.Column("revoked_amount", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
+ sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'active'")),
+ sa.Column("expired_processed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("biz_key", sa.String(length=180), nullable=False),
+ sa.Column("metadata_json", sa.JSON(), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
+ sa.CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
+ sa.CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
+ sa.CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
+ sa.CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
+ sa.CheckConstraint(
+ "grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
+ name="ck_user_credit_balances_amount_reconciled",
+ ),
+ sa.CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["product_id"], ["credit_products.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["payment_order_id"], ["payment_orders.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(
+ ["subscription_id"],
+ ["user_credit_subscriptions.id"],
+ ondelete="SET NULL",
+ ),
+ sa.ForeignKeyConstraint(["grant_record_id"], ["credit_records.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "ix_user_credit_balances_spendable",
+ "user_credit_balances",
+ ["user_id", "credit_level_rank", "expires_at", "valid_from", "id"],
+ unique=False,
+ postgresql_where=sa.text("unspent_amount > 0 AND revoked_at IS NULL"),
+ )
+ op.create_index(
+ "ix_user_credit_balances_expire_due",
+ "user_credit_balances",
+ ["expires_at", "id"],
+ unique=False,
+ postgresql_where=sa.text(
+ "unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
+ ),
+ )
+ op.create_index(
+ "ix_user_credit_balances_source",
+ "user_credit_balances",
+ ["source_type", "source_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_credit_balances_payment",
+ "user_credit_balances",
+ ["payment_order_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_credit_balances_subscription",
+ "user_credit_balances",
+ ["subscription_id", "subscription_period_id"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_user_credit_balances_user_biz_key",
+ "user_credit_balances",
+ ["user_id", "biz_key"],
+ unique=True,
+ )
+ op.create_index("ix_user_credit_balances_user_id", "user_credit_balances", ["user_id"], unique=False)
+ op.create_index("ix_user_credit_balances_source_type", "user_credit_balances", ["source_type"], unique=False)
+ op.create_index("ix_user_credit_balances_source_id", "user_credit_balances", ["source_id"], unique=False)
+ op.create_index("ix_user_credit_balances_grant_record_id", "user_credit_balances", ["grant_record_id"], unique=False)
+ op.create_index("ix_user_credit_balances_valid_from", "user_credit_balances", ["valid_from"], unique=False)
+ op.create_index("ix_user_credit_balances_expires_at", "user_credit_balances", ["expires_at"], unique=False)
+
+ # 6. 订阅月度发放周期。
+ op.create_table(
+ "user_credit_subscription_periods",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("subscription_id", sa.String(length=32), nullable=False),
+ sa.Column("sequence", sa.Integer(), nullable=False),
+ sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("grant_credits", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("allocated_paid_amount", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'scheduled'")),
+ sa.Column("issued_balance_id", sa.String(length=32), nullable=True),
+ sa.Column("issued_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("upgrade_order_id", sa.String(length=32), nullable=True),
+ sa.Column("reserved_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["subscription_id"],
+ ["user_credit_subscriptions.id"],
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(
+ ["issued_balance_id"],
+ ["user_credit_balances.id"],
+ ondelete="SET NULL",
+ ),
+ sa.ForeignKeyConstraint(
+ ["upgrade_order_id"],
+ ["payment_orders.id"],
+ ondelete="SET NULL",
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "uq_user_credit_subscription_periods_sequence",
+ "user_credit_subscription_periods",
+ ["subscription_id", "sequence"],
+ unique=True,
+ )
+ op.create_index(
+ "ix_user_credit_subscription_periods_due",
+ "user_credit_subscription_periods",
+ ["status", "scheduled_at", "id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_user_credit_subscription_periods_upgrade",
+ "user_credit_subscription_periods",
+ ["upgrade_order_id", "status"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_user_credit_subscription_periods_balance",
+ "user_credit_subscription_periods",
+ ["issued_balance_id"],
+ unique=True,
+ )
+ op.create_foreign_key(
+ "fk_user_credit_balances_subscription_period_id",
+ "user_credit_balances",
+ "user_credit_subscription_periods",
+ ["subscription_period_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+ # 7. 积分消费/退款来源分摊。
+ op.create_table(
+ "credit_record_allocations",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("credit_record_id", sa.String(length=32), nullable=False),
+ sa.Column("credit_balance_id", sa.String(length=32), nullable=False),
+ sa.Column("user_id", sa.String(length=32), nullable=False),
+ sa.Column("source_allocation_id", sa.String(length=32), nullable=True),
+ sa.Column("allocation_action", sa.String(length=48), nullable=False),
+ sa.Column("amount", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("request_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("credit_level_snapshot", sa.String(length=32), nullable=False),
+ sa.Column("source_type_snapshot", sa.String(length=48), nullable=False),
+ sa.Column("source_id_snapshot", sa.String(length=64), nullable=True),
+ sa.Column("valid_from_snapshot", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at_snapshot", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("unspent_before", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("unspent_after", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("consumed_before", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("consumed_after", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(
+ ["credit_balance_id"],
+ ["user_credit_balances.id"],
+ ondelete="RESTRICT",
+ ),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(
+ ["source_allocation_id"],
+ ["credit_record_allocations.id"],
+ ondelete="SET NULL",
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "ix_credit_record_allocations_record",
+ "credit_record_allocations",
+ ["credit_record_id", "id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_credit_record_allocations_balance",
+ "credit_record_allocations",
+ ["credit_balance_id", "created_at"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_credit_record_allocations_user_time",
+ "credit_record_allocations",
+ ["user_id", "created_at"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_credit_record_allocations_source_allocation",
+ "credit_record_allocations",
+ ["source_allocation_id"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_credit_record_allocations_allocation_action",
+ "credit_record_allocations",
+ ["allocation_action"],
+ unique=False,
+ )
+
+ # 8. 扩展积分流水。
+ op.drop_index("uq_credit_records_user_refund_target", table_name="credit_records")
+ op.alter_column(
+ "credit_records",
+ "type",
+ existing_type=sa.String(length=16),
+ type_=sa.String(length=24),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "credit_records",
+ "amount",
+ existing_type=sa.Float(),
+ type_=sa.Numeric(precision=20, scale=2),
+ existing_nullable=False,
+ postgresql_using="amount::numeric(20,2)",
+ )
+ op.alter_column(
+ "credit_records",
+ "balance_after",
+ existing_type=sa.Float(),
+ type_=sa.Numeric(precision=20, scale=2),
+ existing_nullable=False,
+ postgresql_using="balance_after::numeric(20,2)",
+ )
+ op.alter_column(
+ "credit_records",
+ "description",
+ existing_type=sa.String(length=256),
+ type_=sa.String(length=512),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "credit_records",
+ "biz_key",
+ existing_type=sa.String(length=160),
+ type_=sa.String(length=180),
+ existing_nullable=True,
+ )
+ op.alter_column(
+ "credit_records",
+ "refund_for_biz_key",
+ existing_type=sa.String(length=160),
+ type_=sa.String(length=180),
+ existing_nullable=True,
+ )
+ op.alter_column(
+ "credit_records",
+ "charge_action",
+ existing_type=sa.String(length=16),
+ type_=sa.String(length=24),
+ existing_nullable=True,
+ )
+ op.add_column(
+ "credit_records",
+ sa.Column(
+ "balance_delta",
+ sa.Numeric(precision=20, scale=2),
+ nullable=False,
+ server_default=sa.text("0"),
+ ),
+ )
+ op.add_column(
+ "credit_records",
+ sa.Column(
+ "expired_amount",
+ sa.Numeric(precision=20, scale=2),
+ nullable=False,
+ server_default=sa.text("0"),
+ ),
+ )
+ op.add_column("credit_records", sa.Column("request_time", sa.DateTime(timezone=True), nullable=True))
+ op.add_column("credit_records", sa.Column("refund_kind", sa.String(length=32), nullable=True))
+ op.add_column("credit_records", sa.Column("scene_name_snapshot", sa.String(length=128), nullable=True))
+ op.add_column("credit_records", sa.Column("credit_level_snapshot", sa.String(length=32), nullable=True))
+ op.add_column("credit_records", sa.Column("llm_call_count", sa.Integer(), nullable=True))
+ op.add_column("credit_records", sa.Column("llm_success_call_count", sa.Integer(), nullable=True))
+ op.add_column("credit_records", sa.Column("llm_failed_call_count", sa.Integer(), nullable=True))
+ op.add_column("credit_records", sa.Column("llm_billing_execution_id", sa.String(length=32), nullable=True))
+ op.create_index("ix_credit_records_request_time", "credit_records", ["request_time"], unique=False)
+ op.create_index("ix_credit_records_refund_kind", "credit_records", ["refund_kind"], unique=False)
+ op.create_index(
+ "ix_credit_records_llm_billing_execution_id",
+ "credit_records",
+ ["llm_billing_execution_id"],
+ unique=False,
+ )
+ op.create_index(
+ "uq_credit_records_user_refund_target_kind",
+ "credit_records",
+ ["user_id", "refund_for_biz_key", "refund_kind"],
+ unique=True,
+ postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
+ )
+
+ # 9. LLM 场景固定预扣及逐次调用审计。
+ op.create_table(
+ "llm_billing_policies",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("scene_code", sa.String(length=64), nullable=False),
+ sa.Column("scene_name", sa.String(length=128), nullable=False),
+ sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ sa.Column("version", sa.Integer(), nullable=False, server_default=sa.text("1")),
+ 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),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "uq_llm_billing_policies_scene",
+ "llm_billing_policies",
+ ["scene_code"],
+ unique=True,
+ )
+
+ op.create_table(
+ "llm_billing_executions",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("user_id", sa.String(length=32), nullable=False),
+ sa.Column("scene_code", sa.String(length=64), nullable=False),
+ sa.Column("scene_name_snapshot", sa.String(length=128), nullable=False),
+ sa.Column("owner_type", sa.String(length=64), nullable=False),
+ sa.Column("owner_id", sa.String(length=64), nullable=False),
+ sa.Column("business_attempt_no", sa.Integer(), nullable=False),
+ sa.Column("model_config_id", sa.String(length=32), nullable=True),
+ sa.Column("model_name_snapshot", sa.String(length=128), nullable=True),
+ sa.Column("provider_snapshot", sa.String(length=64), nullable=True),
+ sa.Column("model_parameters_snapshot", sa.JSON(), nullable=True),
+ sa.Column("billing_policy_id", sa.String(length=32), nullable=True),
+ sa.Column("billing_policy_version", sa.Integer(), nullable=True),
+ sa.Column("request_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("pre_deduct_credits", sa.Numeric(precision=20, scale=2), nullable=False),
+ sa.Column("credit_record_id", sa.String(length=32), nullable=False),
+ sa.Column("status", sa.String(length=32), nullable=False, server_default=sa.text("'pre_deducted'")),
+ sa.Column("total_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("successful_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("failed_call_count", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("total_input_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("total_output_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("total_tokens", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("refund_available_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
+ sa.Column("refund_expired_credits", sa.Numeric(precision=20, scale=2), nullable=False, server_default=sa.text("0")),
+ sa.Column("final_error_message", sa.String(length=1000), nullable=True),
+ sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("refunded_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["billing_policy_id"], ["llm_billing_policies.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["credit_record_id"], ["credit_records.id"], ondelete="RESTRICT"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "uq_llm_billing_executions_business_attempt",
+ "llm_billing_executions",
+ ["user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no"],
+ unique=True,
+ )
+ op.create_index(
+ "uq_llm_billing_executions_credit_record",
+ "llm_billing_executions",
+ ["credit_record_id"],
+ unique=True,
+ )
+ op.create_index(
+ "ix_llm_billing_executions_status_time",
+ "llm_billing_executions",
+ ["status", "created_at"],
+ unique=False,
+ )
+ op.create_index("ix_llm_billing_executions_user_id", "llm_billing_executions", ["user_id"], unique=False)
+ op.create_index("ix_llm_billing_executions_scene_code", "llm_billing_executions", ["scene_code"], unique=False)
+
+ op.create_table(
+ "llm_call_attempts",
+ sa.Column("id", sa.String(length=32), nullable=False),
+ sa.Column("billing_execution_id", sa.String(length=32), nullable=False),
+ sa.Column("call_sequence", sa.Integer(), nullable=False),
+ sa.Column("retry_sequence", sa.Integer(), nullable=False, server_default=sa.text("0")),
+ sa.Column("model_config_id", sa.String(length=32), nullable=True),
+ sa.Column("model_name_snapshot", sa.String(length=128), nullable=True),
+ sa.Column("provider_snapshot", sa.String(length=64), nullable=True),
+ sa.Column("provider_request_id", sa.String(length=160), nullable=True),
+ sa.Column("request_started_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("response_received_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("duration_ms", sa.Integer(), nullable=True),
+ sa.Column("status", sa.String(length=24), nullable=False, server_default=sa.text("'started'")),
+ sa.Column("input_tokens", sa.Integer(), nullable=True),
+ sa.Column("output_tokens", sa.Integer(), nullable=True),
+ sa.Column("total_tokens", sa.Integer(), nullable=True),
+ sa.Column("token_usage_id", sa.String(length=32), nullable=True),
+ sa.Column("http_status", sa.Integer(), nullable=True),
+ sa.Column("provider_error_code", sa.String(length=128), nullable=True),
+ sa.Column("error_message", sa.String(length=1000), nullable=True),
+ sa.Column("token_unavailable_reason", sa.String(length=256), nullable=True),
+ sa.Column("postprocess_status", sa.String(length=32), nullable=True),
+ sa.Column("postprocess_error", sa.String(length=1000), nullable=True),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.ForeignKeyConstraint(
+ ["billing_execution_id"],
+ ["llm_billing_executions.id"],
+ ondelete="CASCADE",
+ ),
+ sa.ForeignKeyConstraint(["model_config_id"], ["model_configs.id"], ondelete="SET NULL"),
+ sa.ForeignKeyConstraint(["token_usage_id"], ["token_usage.id"], ondelete="SET NULL"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index(
+ "uq_llm_call_attempts_sequence",
+ "llm_call_attempts",
+ ["billing_execution_id", "call_sequence"],
+ unique=True,
+ )
+ op.create_index(
+ "ix_llm_call_attempts_execution_time",
+ "llm_call_attempts",
+ ["billing_execution_id", "created_at"],
+ unique=False,
+ )
+ op.create_index(
+ "ix_llm_call_attempts_provider_request",
+ "llm_call_attempts",
+ ["provider_request_id"],
+ unique=False,
+ )
+
+
+
+def downgrade() -> None:
+ # 恢复 users.credits 与旧充值包表。
+
+ op.drop_index("ix_llm_call_attempts_provider_request", table_name="llm_call_attempts")
+ op.drop_index("ix_llm_call_attempts_execution_time", table_name="llm_call_attempts")
+ op.drop_index("uq_llm_call_attempts_sequence", table_name="llm_call_attempts")
+ op.drop_table("llm_call_attempts")
+
+ op.drop_index("ix_llm_billing_executions_scene_code", table_name="llm_billing_executions")
+ op.drop_index("ix_llm_billing_executions_user_id", table_name="llm_billing_executions")
+ op.drop_index("ix_llm_billing_executions_status_time", table_name="llm_billing_executions")
+ op.drop_index("uq_llm_billing_executions_credit_record", table_name="llm_billing_executions")
+ op.drop_index("uq_llm_billing_executions_business_attempt", table_name="llm_billing_executions")
+ op.drop_table("llm_billing_executions")
+
+ op.drop_index("uq_llm_billing_policies_scene", table_name="llm_billing_policies")
+ op.drop_table("llm_billing_policies")
+
+ op.drop_index("uq_credit_records_user_refund_target_kind", table_name="credit_records")
+ op.drop_index("ix_credit_records_llm_billing_execution_id", table_name="credit_records")
+ op.drop_index("ix_credit_records_refund_kind", table_name="credit_records")
+ op.drop_index("ix_credit_records_request_time", table_name="credit_records")
+ op.drop_column("credit_records", "llm_billing_execution_id")
+ op.drop_column("credit_records", "llm_failed_call_count")
+ op.drop_column("credit_records", "llm_success_call_count")
+ op.drop_column("credit_records", "llm_call_count")
+ op.drop_column("credit_records", "credit_level_snapshot")
+ op.drop_column("credit_records", "scene_name_snapshot")
+ op.drop_column("credit_records", "refund_kind")
+ op.drop_column("credit_records", "request_time")
+ op.drop_column("credit_records", "expired_amount")
+ op.drop_column("credit_records", "balance_delta")
+ op.alter_column(
+ "credit_records",
+ "charge_action",
+ existing_type=sa.String(length=24),
+ type_=sa.String(length=16),
+ existing_nullable=True,
+ )
+ op.alter_column(
+ "credit_records",
+ "refund_for_biz_key",
+ existing_type=sa.String(length=180),
+ type_=sa.String(length=160),
+ existing_nullable=True,
+ )
+ op.alter_column(
+ "credit_records",
+ "biz_key",
+ existing_type=sa.String(length=180),
+ type_=sa.String(length=160),
+ existing_nullable=True,
+ )
+ op.alter_column(
+ "credit_records",
+ "description",
+ existing_type=sa.String(length=512),
+ type_=sa.String(length=256),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "credit_records",
+ "balance_after",
+ existing_type=sa.Numeric(precision=20, scale=2),
+ type_=sa.Float(),
+ existing_nullable=False,
+ postgresql_using="balance_after::double precision",
+ )
+ op.alter_column(
+ "credit_records",
+ "amount",
+ existing_type=sa.Numeric(precision=20, scale=2),
+ type_=sa.Float(),
+ existing_nullable=False,
+ postgresql_using="amount::double precision",
+ )
+ op.alter_column(
+ "credit_records",
+ "type",
+ existing_type=sa.String(length=24),
+ type_=sa.String(length=16),
+ existing_nullable=False,
+ )
+ op.create_index(
+ "uq_credit_records_user_refund_target",
+ "credit_records",
+ ["user_id", "refund_for_biz_key"],
+ unique=True,
+ postgresql_where=sa.text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
+ )
+
+ op.drop_index("ix_credit_record_allocations_allocation_action", table_name="credit_record_allocations")
+ op.drop_index("ix_credit_record_allocations_source_allocation", table_name="credit_record_allocations")
+ op.drop_index("ix_credit_record_allocations_user_time", table_name="credit_record_allocations")
+ op.drop_index("ix_credit_record_allocations_balance", table_name="credit_record_allocations")
+ op.drop_index("ix_credit_record_allocations_record", table_name="credit_record_allocations")
+ op.drop_table("credit_record_allocations")
+
+ op.drop_constraint(
+ "fk_user_credit_balances_subscription_period_id",
+ "user_credit_balances",
+ type_="foreignkey",
+ )
+ op.drop_index("uq_user_credit_subscription_periods_balance", table_name="user_credit_subscription_periods")
+ op.drop_index("ix_user_credit_subscription_periods_upgrade", table_name="user_credit_subscription_periods")
+ op.drop_index("ix_user_credit_subscription_periods_due", table_name="user_credit_subscription_periods")
+ op.drop_index("uq_user_credit_subscription_periods_sequence", table_name="user_credit_subscription_periods")
+ op.drop_table("user_credit_subscription_periods")
+
+ op.drop_index("ix_user_credit_balances_expires_at", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_valid_from", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_grant_record_id", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_source_id", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_source_type", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_user_id", table_name="user_credit_balances")
+ op.drop_index("uq_user_credit_balances_user_biz_key", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_subscription", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_payment", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_source", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_expire_due", table_name="user_credit_balances")
+ op.drop_index("ix_user_credit_balances_spendable", table_name="user_credit_balances")
+ op.drop_table("user_credit_balances")
+
+ op.drop_index("ix_user_credit_subscriptions_user_id", table_name="user_credit_subscriptions")
+ op.drop_index("uq_user_credit_subscriptions_payment", table_name="user_credit_subscriptions")
+ op.drop_index("ix_user_credit_subscriptions_grant_due", table_name="user_credit_subscriptions")
+ op.drop_index("ix_user_credit_subscriptions_expire_due", table_name="user_credit_subscriptions")
+ op.drop_index("ix_user_credit_subscriptions_current", table_name="user_credit_subscriptions")
+ op.drop_table("user_credit_subscriptions")
+
+ op.drop_index("ix_payment_orders_fulfillment_status", table_name="payment_orders")
+ op.drop_index("ix_payment_orders_source_subscription_id", table_name="payment_orders")
+ op.drop_index("ix_payment_orders_subscription_id", table_name="payment_orders")
+ op.drop_index("ix_payment_orders_product_type", table_name="payment_orders")
+ op.drop_index("ix_payment_orders_product_id", table_name="payment_orders")
+ op.drop_constraint(
+ "fk_payment_orders_product_id_credit_products",
+ "payment_orders",
+ type_="foreignkey",
+ )
+ op.drop_column("payment_orders", "fulfilled_at")
+ op.drop_column("payment_orders", "fulfillment_status")
+ op.drop_column("payment_orders", "payable_amount_snapshot")
+ op.drop_column("payment_orders", "deduction_amount_snapshot")
+ op.drop_column("payment_orders", "target_price_snapshot")
+ op.drop_column("payment_orders", "upgrade_period_ids_json")
+ op.drop_column("payment_orders", "source_subscription_id")
+ op.drop_column("payment_orders", "subscription_id")
+ op.drop_column("payment_orders", "product_snapshot_json")
+ op.drop_column("payment_orders", "product_name_snapshot")
+ op.drop_column("payment_orders", "product_code_snapshot")
+ op.drop_column("payment_orders", "price_type")
+ op.drop_column("payment_orders", "purchase_scene")
+ op.drop_column("payment_orders", "product_type")
+ op.drop_column("payment_orders", "product_id")
+ op.alter_column(
+ "payment_orders",
+ "refund_amount",
+ existing_type=sa.Numeric(precision=20, scale=2),
+ type_=sa.Float(),
+ existing_nullable=True,
+ postgresql_using="refund_amount::double precision",
+ )
+ op.alter_column(
+ "payment_orders",
+ "status",
+ existing_type=sa.String(length=32),
+ type_=sa.String(length=16),
+ existing_nullable=False,
+ )
+ op.alter_column(
+ "payment_orders",
+ "credits",
+ existing_type=sa.Numeric(precision=20, scale=2),
+ type_=sa.Float(),
+ existing_nullable=False,
+ server_default=None,
+ postgresql_using="credits::double precision",
+ )
+ op.alter_column(
+ "payment_orders",
+ "amount",
+ existing_type=sa.Numeric(precision=20, scale=2),
+ type_=sa.Float(),
+ existing_nullable=False,
+ postgresql_using="amount::double precision",
+ )
+
+ op.drop_index("ix_users_first_membership_paid_at", table_name="users")
+ op.drop_column("users", "first_membership_paid_at")
+
+ op.drop_index("ix_credit_products_product_type", table_name="credit_products")
+ op.drop_index("ix_credit_products_public", table_name="credit_products")
+ op.drop_index("uq_credit_products_code", table_name="credit_products")
+ op.drop_table("credit_products")
diff --git a/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py b/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py
new file mode 100644
index 00000000..680714f2
--- /dev/null
+++ b/video-gen-api/alembic/versions/8a6d2f4c9b10_add_credit_product_renewal_enabled.py
@@ -0,0 +1,650 @@
+"""add credit product renewal enabled
+
+Revision ID: 8a6d2f4c9b10
+Revises: 7f3c8a2d9e41
+Create Date: 2026-08-06 17:20:00
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "8a6d2f4c9b10"
+down_revision = "7f3c8a2d9e41"
+branch_labels = None
+depends_on = None
+
+
+_PRODUCT_UPSERT_SQL = sa.text(
+ """
+ INSERT INTO credit_products (
+ id,
+ product_code,
+ product_type,
+ name,
+ description,
+ features_json,
+ tier_code,
+ tier_rank,
+ billing_cycle,
+ monthly_grant_credits,
+ first_purchase_price,
+ regular_price,
+ activity_price,
+ activity_start_at,
+ activity_end_at,
+ grant_credits,
+ validity_months,
+ price,
+ credit_level,
+ currency,
+ is_active,
+ sort_order,
+ created_at,
+ updated_at,
+ renewal_enabled
+ ) VALUES (
+ :id,
+ :product_code,
+ :product_type,
+ :name,
+ :description,
+ CAST(:features_json AS JSON),
+ :tier_code,
+ :tier_rank,
+ :billing_cycle,
+ :monthly_grant_credits,
+ :first_purchase_price,
+ :regular_price,
+ :activity_price,
+ :activity_start_at,
+ :activity_end_at,
+ :grant_credits,
+ :validity_months,
+ :price,
+ :credit_level,
+ :currency,
+ :is_active,
+ :sort_order,
+ :created_at,
+ :updated_at,
+ :renewal_enabled
+ )
+ ON CONFLICT (product_code) DO UPDATE SET
+ product_type = EXCLUDED.product_type,
+ name = EXCLUDED.name,
+ description = EXCLUDED.description,
+ features_json = EXCLUDED.features_json,
+ tier_code = EXCLUDED.tier_code,
+ tier_rank = EXCLUDED.tier_rank,
+ billing_cycle = EXCLUDED.billing_cycle,
+ monthly_grant_credits = EXCLUDED.monthly_grant_credits,
+ first_purchase_price = EXCLUDED.first_purchase_price,
+ regular_price = EXCLUDED.regular_price,
+ activity_price = EXCLUDED.activity_price,
+ activity_start_at = EXCLUDED.activity_start_at,
+ activity_end_at = EXCLUDED.activity_end_at,
+ grant_credits = EXCLUDED.grant_credits,
+ validity_months = EXCLUDED.validity_months,
+ price = EXCLUDED.price,
+ credit_level = EXCLUDED.credit_level,
+ currency = EXCLUDED.currency,
+ is_active = EXCLUDED.is_active,
+ sort_order = EXCLUDED.sort_order,
+ updated_at = EXCLUDED.updated_at,
+ renewal_enabled = EXCLUDED.renewal_enabled
+ """
+)
+
+
+_POLICY_UPSERT_SQL = sa.text(
+ """
+ INSERT INTO llm_billing_policies (
+ id,
+ scene_code,
+ scene_name,
+ pre_deduct_credits,
+ is_active,
+ version,
+ created_by,
+ updated_by,
+ created_at,
+ updated_at
+ ) VALUES (
+ :id,
+ :scene_code,
+ :scene_name,
+ :pre_deduct_credits,
+ :is_active,
+ :version,
+ :created_by,
+ :updated_by,
+ :created_at,
+ :updated_at
+ )
+ ON CONFLICT (scene_code) DO UPDATE SET
+ scene_name = EXCLUDED.scene_name,
+ pre_deduct_credits = EXCLUDED.pre_deduct_credits,
+ is_active = EXCLUDED.is_active,
+ version = EXCLUDED.version,
+ updated_by = EXCLUDED.updated_by,
+ updated_at = EXCLUDED.updated_at
+ """
+)
+
+
+def _dt(value: str) -> datetime:
+ return datetime.fromisoformat(value)
+
+
+def _seed_credit_products() -> None:
+ rows = [
+ {
+ "id": "0019fd653e4680767ab",
+ "product_code": "1",
+ "product_type": "subscription",
+ "name": "入门",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "入门",
+ "tier_rank": 1,
+ "billing_cycle": "monthly",
+ "monthly_grant_credits": Decimal("1000.00"),
+ "first_purchase_price": Decimal("79.00"),
+ "regular_price": Decimal("122.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("122.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 1,
+ "created_at": _dt("2026-08-06T09:07:30.020043+08:00"),
+ "updated_at": _dt("2026-08-06T09:52:15.579827+08:00"),
+ "renewal_enabled": False,
+ },
+ {
+ "id": "0019fd67e8a786dda0e",
+ "product_code": "2",
+ "product_type": "subscription",
+ "name": "标准",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "标准",
+ "tier_rank": 2,
+ "billing_cycle": "monthly",
+ "monthly_grant_credits": Decimal("2000.00"),
+ "first_purchase_price": Decimal("154.00"),
+ "regular_price": Decimal("239.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("239.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 2,
+ "created_at": _dt("2026-08-06T09:54:05.049302+08:00"),
+ "updated_at": _dt("2026-08-06T09:54:05.049302+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd67f4c8bf75dd1",
+ "product_code": "3",
+ "product_type": "subscription",
+ "name": "高级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "高级",
+ "tier_rank": 3,
+ "billing_cycle": "monthly",
+ "monthly_grant_credits": Decimal("3000.00"),
+ "first_purchase_price": Decimal("221.00"),
+ "regular_price": Decimal("353.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("353.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 3,
+ "created_at": _dt("2026-08-06T09:54:54.768868+08:00"),
+ "updated_at": _dt("2026-08-06T09:55:10.408471+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd680784c16dd15",
+ "product_code": "4",
+ "product_type": "subscription",
+ "name": "超级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "超级",
+ "tier_rank": 4,
+ "billing_cycle": "monthly",
+ "monthly_grant_credits": Decimal("4000.00"),
+ "first_purchase_price": Decimal("280.00"),
+ "regular_price": Decimal("476.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("476.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 4,
+ "created_at": _dt("2026-08-06T09:56:11.442596+08:00"),
+ "updated_at": _dt("2026-08-06T09:56:11.442596+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd68270abc3dcbd",
+ "product_code": "5",
+ "product_type": "subscription",
+ "name": "标准",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "标准",
+ "tier_rank": 2,
+ "billing_cycle": "quarterly",
+ "monthly_grant_credits": Decimal("5000.00"),
+ "first_purchase_price": Decimal("1029.00"),
+ "regular_price": Decimal("1499.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("1499.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 5,
+ "created_at": _dt("2026-08-06T09:58:20.601505+08:00"),
+ "updated_at": _dt("2026-08-06T09:58:20.601505+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd68373d24dbab0",
+ "product_code": "6",
+ "product_type": "subscription",
+ "name": "高级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "高级",
+ "tier_rank": 3,
+ "billing_cycle": "quarterly",
+ "monthly_grant_credits": Decimal("6000.00"),
+ "first_purchase_price": Decimal("1197.00"),
+ "regular_price": Decimal("1791.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("1791.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 6,
+ "created_at": _dt("2026-08-06T09:59:26.972669+08:00"),
+ "updated_at": _dt("2026-08-06T09:59:26.972669+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd68469706aa522",
+ "product_code": "7",
+ "product_type": "subscription",
+ "name": "超级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "超级",
+ "tier_rank": 4,
+ "billing_cycle": "quarterly",
+ "monthly_grant_credits": Decimal("7000.00"),
+ "first_purchase_price": Decimal("1367.00"),
+ "regular_price": Decimal("2079.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("2079.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 7,
+ "created_at": _dt("2026-08-06T10:00:29.843562+08:00"),
+ "updated_at": _dt("2026-08-06T10:00:29.843562+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd6904f72f59505",
+ "product_code": "8",
+ "product_type": "subscription",
+ "name": "标准",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "标准",
+ "tier_rank": 2,
+ "billing_cycle": "yearly",
+ "monthly_grant_credits": Decimal("10000.00"),
+ "first_purchase_price": Decimal("7308.00"),
+ "regular_price": Decimal("8399.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("8399.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 8,
+ "created_at": _dt("2026-08-06T10:13:29.614018+08:00"),
+ "updated_at": _dt("2026-08-06T10:13:29.614018+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd692bc5e42b8be",
+ "product_code": "9",
+ "product_type": "subscription",
+ "name": "高级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "高级",
+ "tier_rank": 3,
+ "billing_cycle": "yearly",
+ "monthly_grant_credits": Decimal("15000.00"),
+ "first_purchase_price": Decimal("10710.00"),
+ "regular_price": Decimal("12586.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("12586.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 9,
+ "created_at": _dt("2026-08-06T10:16:08.545547+08:00"),
+ "updated_at": _dt("2026-08-06T10:16:08.545547+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd69417fabda8c6",
+ "product_code": "10",
+ "product_type": "subscription",
+ "name": "超级",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": "超级",
+ "tier_rank": 4,
+ "billing_cycle": "yearly",
+ "monthly_grant_credits": Decimal("20000.00"),
+ "first_purchase_price": Decimal("13440.00"),
+ "regular_price": Decimal("16685.00"),
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": None,
+ "validity_months": None,
+ "price": Decimal("16685.00"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 10,
+ "created_at": _dt("2026-08-06T10:17:37.548293+08:00"),
+ "updated_at": _dt("2026-08-06T10:17:37.548293+08:00"),
+ "renewal_enabled": True,
+ },
+ {
+ "id": "0019fd69a07e363c7b5",
+ "product_code": "ZLB-A",
+ "product_type": "credit_addon",
+ "name": "增量包A",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": None,
+ "tier_rank": None,
+ "billing_cycle": None,
+ "monthly_grant_credits": None,
+ "first_purchase_price": None,
+ "regular_price": None,
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": Decimal("500.00"),
+ "validity_months": 1,
+ "price": Decimal("0.01"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 1,
+ "created_at": _dt("2026-08-06T10:24:06.646673+08:00"),
+ "updated_at": _dt("2026-08-06T10:24:06.646673+08:00"),
+ "renewal_enabled": False,
+ },
+ {
+ "id": "0019fd69a640d7ccc92",
+ "product_code": "ZLB-B",
+ "product_type": "credit_addon",
+ "name": "增量包B",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": None,
+ "tier_rank": None,
+ "billing_cycle": None,
+ "monthly_grant_credits": None,
+ "first_purchase_price": None,
+ "regular_price": None,
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": Decimal("1000.00"),
+ "validity_months": 1,
+ "price": Decimal("0.02"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 2,
+ "created_at": _dt("2026-08-06T10:24:30.256294+08:00"),
+ "updated_at": _dt("2026-08-06T10:24:35.878389+08:00"),
+ "renewal_enabled": False,
+ },
+ {
+ "id": "0019fd69ae039d3ba69",
+ "product_code": "ZLB-C",
+ "product_type": "credit_addon",
+ "name": "增量包C",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": None,
+ "tier_rank": None,
+ "billing_cycle": None,
+ "monthly_grant_credits": None,
+ "first_purchase_price": None,
+ "regular_price": None,
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": Decimal("1500.00"),
+ "validity_months": 1,
+ "price": Decimal("0.03"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 3,
+ "created_at": _dt("2026-08-06T10:25:02.043620+08:00"),
+ "updated_at": _dt("2026-08-06T10:25:02.043620+08:00"),
+ "renewal_enabled": False,
+ },
+ {
+ "id": "0019fd69b3166a6a71c",
+ "product_code": "ZLB-D",
+ "product_type": "credit_addon",
+ "name": "增量包D",
+ "description": None,
+ "features_json": "[]",
+ "tier_code": None,
+ "tier_rank": None,
+ "billing_cycle": None,
+ "monthly_grant_credits": None,
+ "first_purchase_price": None,
+ "regular_price": None,
+ "activity_price": None,
+ "activity_start_at": None,
+ "activity_end_at": None,
+ "grant_credits": Decimal("2000.00"),
+ "validity_months": 1,
+ "price": Decimal("0.04"),
+ "credit_level": "general",
+ "currency": "CNY",
+ "is_active": True,
+ "sort_order": 4,
+ "created_at": _dt("2026-08-06T10:25:22.811498+08:00"),
+ "updated_at": _dt("2026-08-06T10:25:29.647103+08:00"),
+ "renewal_enabled": False,
+ },
+ ]
+
+ connection = op.get_bind()
+ for row in rows:
+ connection.execute(_PRODUCT_UPSERT_SQL, row)
+
+
+def _seed_llm_billing_policies() -> None:
+ admin_user_id = "0019e0a44895b6d837d"
+ rows = [
+ {
+ "id": "0019fd6aa5084a85a96",
+ "scene_code": "generation_record_text_prompt_optimize",
+ "scene_name": "AI创作-提示词优化",
+ "pre_deduct_credits": Decimal("5.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:41:53.809940+08:00"),
+ "updated_at": _dt("2026-08-06T10:41:53.809940+08:00"),
+ },
+ {
+ "id": "0019fd6aa834169f61d",
+ "scene_code": "hot_opening_image_prompt_optimize",
+ "scene_name": "爆款开头复刻-图片提示词优化",
+ "pre_deduct_credits": Decimal("5.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:42:06.818840+08:00"),
+ "updated_at": _dt("2026-08-06T10:42:06.818840+08:00"),
+ },
+ {
+ "id": "0019fd6aac862f821ca",
+ "scene_code": "shot_image_prompt_optimize",
+ "scene_name": "拆镜复刻-图片提示词优化",
+ "pre_deduct_credits": Decimal("5.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:42:24.496938+08:00"),
+ "updated_at": _dt("2026-08-06T10:42:24.496938+08:00"),
+ },
+ {
+ "id": "0019fd6aaf2fcae0765",
+ "scene_code": "shot_video_prompt_optimize",
+ "scene_name": "拆镜复刻-视频提示词优化",
+ "pre_deduct_credits": Decimal("20.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:42:35.404414+08:00"),
+ "updated_at": _dt("2026-08-06T10:42:35.404414+08:00"),
+ },
+ {
+ "id": "0019fd6aaaf31d7c7c7",
+ "scene_code": "hot_opening_video_prompt_optimize",
+ "scene_name": "爆款开头复刻-视频提示词优化",
+ "pre_deduct_credits": Decimal("20.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:42:18.003357+08:00"),
+ "updated_at": _dt("2026-08-06T10:42:39.440194+08:00"),
+ },
+ {
+ "id": "0019fd6abba149ef7e9",
+ "scene_code": "shot_original_video_analysis",
+ "scene_name": "拆镜复刻-原视频AI分析",
+ "pre_deduct_credits": Decimal("20.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:43:26.390337+08:00"),
+ "updated_at": _dt("2026-08-06T10:43:26.390337+08:00"),
+ },
+ {
+ "id": "0019fd6abdbc4182c17",
+ "scene_code": "shot_segment_video_analysis",
+ "scene_name": "拆镜复刻-片段视频AI分析",
+ "pre_deduct_credits": Decimal("10.00"),
+ "is_active": True,
+ "version": 1,
+ "created_by": admin_user_id,
+ "updated_by": admin_user_id,
+ "created_at": _dt("2026-08-06T10:43:35.017213+08:00"),
+ "updated_at": _dt("2026-08-06T10:43:35.017213+08:00"),
+ },
+ ]
+
+ connection = op.get_bind()
+ for row in rows:
+ connection.execute(_POLICY_UPSERT_SQL, row)
+
+
+def upgrade() -> None:
+ op.add_column(
+ "credit_products",
+ sa.Column(
+ "renewal_enabled",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.text("true"),
+ ),
+ )
+
+ # 积分增值包不参与订阅续费,统一标记为关闭;已有订阅套餐默认保持开启。
+ op.execute(
+ "UPDATE credit_products "
+ "SET renewal_enabled = false "
+ "WHERE product_type = 'credit_addon'"
+ )
+
+ _seed_credit_products()
+ _seed_llm_billing_policies()
+
+
+def downgrade() -> None:
+ # 产品和计费策略属于运营配置,降级仅撤销本次新增字段,不主动删除业务配置数据。
+ op.drop_column("credit_products", "renewal_enabled")
diff --git a/video-gen-api/app/api/admin/__init__.py b/video-gen-api/app/api/admin/__init__.py
index 9db3c0bc..193694df 100644
--- a/video-gen-api/app/api/admin/__init__.py
+++ b/video-gen-api/app/api/admin/__init__.py
@@ -7,6 +7,8 @@ from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
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.credit_management import router as credit_management_router
+from app.api.admin.llm_billing import router as llm_billing_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.contact import router as admin_contact_router
@@ -19,6 +21,8 @@ router.include_router(team_router)
router.include_router(home_material_router)
router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
+router.include_router(credit_management_router)
+router.include_router(llm_billing_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(admin_contact_router)
diff --git a/video-gen-api/app/api/admin/credit_management.py b/video-gen-api/app/api/admin/credit_management.py
new file mode 100644
index 00000000..8d8df660
--- /dev/null
+++ b/video-gen-api/app/api/admin/credit_management.py
@@ -0,0 +1,318 @@
+from __future__ import annotations
+
+import json
+from datetime import timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_admin_user, get_db
+from app.enums.credit_balance import CreditBalanceSourceType
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit.product import CreditProduct
+from app.models.user import User
+from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
+from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductUpdate
+from app.services.credit.ledger_service import deduct_credits, grant_credits
+from app.services.credit.product_service import product_to_dict
+from app.services.credit.query_service import (
+ apply_balance_status_filter,
+ effective_balance_status,
+ get_balance_summary,
+)
+from app.services.credit.time_policy import add_natural_months, last_usable_at
+from app.services.credit.utils import utc_now
+from app.services.notification import create_notification
+from app.services.operation_log import log_operation
+from app.services.operation_log_service import log_operation_event
+from app.utils.id_gen import generate_id
+
+router = APIRouter(prefix="/admin/credit-management", tags=["admin-credit-management"])
+
+
+def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
+ mapping = {"features": "features_json"}
+ for key, value in payload.items():
+ setattr(product, mapping.get(key, key), value)
+ if product.product_type == "credit_addon":
+ product.validity_months = 1
+ if product.product_type == "subscription":
+ product.price = product.regular_price or 0
+ product.grant_credits = None
+ product.validity_months = None
+ else:
+ product.renewal_enabled = False
+ product.tier_code = None
+ product.tier_rank = None
+ product.billing_cycle = None
+ product.monthly_grant_credits = None
+ product.first_purchase_price = None
+ product.regular_price = None
+ product.activity_price = None
+ product.activity_start_at = None
+ product.activity_end_at = None
+
+
+def _validate_product_entity(product: CreditProduct) -> None:
+ if product.product_type == "subscription":
+ required = {
+ "套餐等级编码": product.tier_code,
+ "套餐等级顺序": product.tier_rank,
+ "订阅周期": product.billing_cycle,
+ "每月积分": product.monthly_grant_credits,
+ "首充价格": product.first_purchase_price,
+ "原价": product.regular_price,
+ }
+ missing = [label for label, value in required.items() if value is None]
+ if missing:
+ raise HTTPException(status_code=400, detail=f"订阅套餐缺少字段:{'、'.join(missing)}")
+ if product.activity_price is None:
+ if product.activity_start_at is not None or product.activity_end_at is not None:
+ raise HTTPException(status_code=400, detail="未配置活动价时不能单独配置活动周期")
+ elif product.activity_start_at is None or product.activity_end_at is None:
+ raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
+ elif product.activity_end_at <= product.activity_start_at:
+ raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
+ elif product.product_type == "credit_addon":
+ if product.grant_credits is None or product.price is None:
+ raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
+ else:
+ raise HTTPException(status_code=400, detail="不支持的积分商品类型")
+
+
+@router.get("/products")
+async def list_products(
+ product_type: str | None = Query(default=None),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ stmt = select(CreditProduct)
+ if product_type:
+ stmt = stmt.where(CreditProduct.product_type == product_type)
+ result = await db.execute(stmt.order_by(CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id))
+ return [product_to_dict(item) for item in result.scalars().all()]
+
+
+@router.post("/products")
+async def create_product(
+ data: CreditProductCreate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ exists = await db.execute(select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1))
+ if exists.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="商品编码已存在")
+ product = CreditProduct(id=generate_id())
+ _apply_product_payload(product, data.model_dump())
+ _validate_product_entity(product)
+ db.add(product)
+ await db.flush()
+ snapshot = product_to_dict(product)
+ await log_operation(db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST", "/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str))
+ log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED", user_id=admin.id, detail=snapshot)
+ # 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
+ # yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
+ await db.commit()
+ return snapshot
+
+
+@router.put("/products/{product_id}")
+async def update_product(
+ product_id: str,
+ data: CreditProductUpdate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
+ product = result.scalar_one_or_none()
+ if not product:
+ raise HTTPException(status_code=404, detail="商品不存在")
+ before = product_to_dict(product)
+ payload = data.model_dump(exclude_unset=True)
+ new_code = payload.get("product_code")
+ if new_code and new_code != product.product_code:
+ duplicate = await db.execute(
+ select(CreditProduct.id).where(
+ CreditProduct.product_code == new_code, CreditProduct.id != product.id
+ ).limit(1)
+ )
+ if duplicate.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="商品编码已存在")
+ _apply_product_payload(product, payload)
+ _validate_product_entity(product)
+ await db.flush()
+ after = product_to_dict(product)
+ await log_operation(db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT", f"/admin/credit-management/products/{product_id}", detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str))
+ log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id})
+ await db.commit()
+ refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1))
+ persisted = refreshed.scalar_one_or_none()
+ if persisted is None:
+ raise HTTPException(status_code=404, detail="商品不存在")
+ return product_to_dict(persisted)
+
+
+@router.put("/products/{product_id}/renewal")
+async def update_product_renewal(
+ product_id: str,
+ data: CreditProductRenewalUpdate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(CreditProduct)
+ .where(CreditProduct.id == product_id)
+ .limit(1)
+ .with_for_update()
+ )
+ product = result.scalar_one_or_none()
+ if not product:
+ raise HTTPException(status_code=404, detail="商品不存在")
+ if product.product_type != "subscription":
+ raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
+
+ before = bool(product.renewal_enabled)
+ product.renewal_enabled = bool(data.renewal_enabled)
+ await db.flush()
+ await log_operation(
+ db,
+ admin.id,
+ admin.username,
+ f"{'开启' if product.renewal_enabled else '关闭'}积分商品续费 {product.name}",
+ "PUT",
+ f"/admin/credit-management/products/{product_id}/renewal",
+ detail=json.dumps(
+ {"before": before, "after": bool(product.renewal_enabled)},
+ ensure_ascii=False,
+ ),
+ )
+ await db.commit()
+
+ # 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
+ refreshed = await db.execute(
+ select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
+ )
+ persisted = refreshed.scalar_one_or_none()
+ if persisted is None:
+ raise HTTPException(status_code=404, detail="商品不存在")
+ if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
+ raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
+ return product_to_dict(persisted)
+
+
+@router.delete("/products/{product_id}")
+async def disable_product(
+ product_id: str,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
+ product = result.scalar_one_or_none()
+ if not product:
+ raise HTTPException(status_code=404, detail="商品不存在")
+ product.is_active = False
+ await db.flush()
+ await log_operation(db, admin.id, admin.username, f"下架积分商品 {product.name}", "DELETE", f"/admin/credit-management/products/{product_id}")
+ await db.commit()
+ return {"ok": True}
+
+
+@router.get("/users/{user_id}/summary")
+async def get_user_credit_summary(
+ user_id: str,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ return (await get_balance_summary(db, user_id)).to_dict()
+
+
+@router.get("/users/{user_id}/balances")
+async def list_user_credit_balances(
+ user_id: str,
+ status: str | None = Query(default=None),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ checked_at = utc_now()
+ stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
+ stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
+ result = await db.execute(stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()).offset((page - 1) * page_size).limit(page_size))
+ return [
+ {
+ "id": item.id,
+ "credit_level": item.credit_level,
+ "source_type": item.source_type,
+ "source_id": item.source_id,
+ "grant_amount": float(item.grant_amount),
+ "unspent_amount": float(item.unspent_amount),
+ "consumed_amount": float(item.consumed_amount),
+ "expired_amount": float(item.expired_amount),
+ "revoked_amount": float(item.revoked_amount),
+ "valid_from": item.valid_from,
+ "expires_at": item.expires_at,
+ "last_usable_at": last_usable_at(item.expires_at),
+ "status": effective_balance_status(item, request_time=checked_at),
+ }
+ for item in result.scalars().all()
+ ]
+
+
+@router.post("/users/{user_id}/grant")
+async def admin_grant_credit(
+ user_id: str,
+ data: AdminCreditGrantRequest,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ starts_at = data.valid_from or utc_now()
+ ends_at = starts_at + timedelta(days=data.validity_value) if data.validity_unit == "day" else add_natural_months(starts_at, data.validity_value)
+ result = await grant_credits(
+ db,
+ user_id=user_id,
+ amount=data.amount,
+ description=data.description,
+ source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
+ source_id=admin.id,
+ valid_from=starts_at,
+ expires_at=ends_at,
+ credit_level=data.credit_level,
+ related_id=admin.id,
+ biz_key=f"admin-grant:{admin.id}:{generate_id()}",
+ )
+ await create_notification(
+ db, user_id, "积分变动通知",
+ f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit",
+ )
+ await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
+ return {"ok": True, "credits": result.balance_after}
+
+
+@router.post("/users/{user_id}/deduct")
+async def admin_deduct_credit(
+ user_id: str,
+ data: AdminCreditDeductRequest,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ try:
+ result = await deduct_credits(
+ db,
+ user_id=user_id,
+ amount=data.amount,
+ description=data.description,
+ related_id=admin.id,
+ biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
+ )
+ except Exception as exc:
+ if exc.__class__.__name__ == "InsufficientCreditsError":
+ raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
+ raise
+ await create_notification(
+ db, user_id, "积分变动通知",
+ f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit",
+ )
+ await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
+ return {"ok": True, "credits": result.balance_after}
diff --git a/video-gen-api/app/api/admin/llm_billing.py b/video-gen-api/app/api/admin/llm_billing.py
new file mode 100644
index 00000000..2d8c81a1
--- /dev/null
+++ b/video-gen-api/app/api/admin/llm_billing.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_admin_user, get_db
+from app.enums.llm_billing import LLM_BILLING_SCENE_LABELS
+from app.models.llm_billing.policy import LlmBillingPolicyModel
+from app.models.user import User
+from app.schemas.llm_billing import LlmBillingPolicyCreate, LlmBillingPolicyUpdate
+from app.services.llm_billing.query_service import list_executions_with_calls
+from app.services.operation_log_service import log_operation_event
+from app.utils.id_gen import generate_id
+
+router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
+
+
+@router.get("/policies")
+async def list_policies(
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
+ return list(result.scalars().all())
+
+
+@router.post("/policies")
+async def create_policy(
+ data: LlmBillingPolicyCreate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ if data.scene_code not in LLM_BILLING_SCENE_LABELS:
+ raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
+ existing = await db.execute(select(LlmBillingPolicyModel.id).where(LlmBillingPolicyModel.scene_code == data.scene_code).limit(1))
+ if existing.scalar_one_or_none():
+ raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
+ policy = LlmBillingPolicyModel(
+ id=generate_id(),
+ scene_code=data.scene_code,
+ scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
+ pre_deduct_credits=data.pre_deduct_credits,
+ is_active=data.is_active,
+ version=1,
+ created_by=admin.id,
+ updated_by=admin.id,
+ )
+ db.add(policy)
+ await db.flush()
+ log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_CREATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "credits": float(policy.pre_deduct_credits)})
+ return policy
+
+
+@router.put("/policies/{policy_id}")
+async def update_policy(
+ policy_id: str,
+ data: LlmBillingPolicyUpdate,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(select(LlmBillingPolicyModel).where(LlmBillingPolicyModel.id == policy_id).limit(1).with_for_update())
+ policy = result.scalar_one_or_none()
+ if not policy:
+ raise HTTPException(status_code=404, detail="LLM计费场景不存在")
+ payload = data.model_dump(exclude_unset=True)
+ payload.pop("scene_name", None)
+ for key, value in payload.items():
+ setattr(policy, key, value)
+ policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
+ policy.version += 1
+ policy.updated_by = admin.id
+ await db.flush()
+ log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_UPDATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "version": policy.version})
+ return policy
+
+
+@router.get("/executions")
+async def list_executions(
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ scene_code: str | None = Query(default=None),
+ status: str | None = Query(default=None),
+ user_id: str | None = Query(default=None),
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ items, total = await list_executions_with_calls(db, page=page, page_size=page_size, scene_code=scene_code, status=status, user_id=user_id)
+ return {"items": items, "total": total}
diff --git a/video-gen-api/app/api/admin/recharge_package.py b/video-gen-api/app/api/admin/recharge_package.py
index bf23147e..458fbb61 100644
--- a/video-gen-api/app/api/admin/recharge_package.py
+++ b/video-gen-api/app/api/admin/recharge_package.py
@@ -1,148 +1,24 @@
-import json
-
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.dependencies import get_db, get_admin_user
+from app.dependencies import get_admin_user, get_db
+from app.enums.credit_product import CreditProductType
+from app.models.credit.product import CreditProduct
from app.models.user import User
-from app.models.recharge_package import RechargePackage
-from app.schemas.recharge_package import (
- RechargePackageCreate,
- RechargePackageUpdate,
- RechargePackageOut,
-)
-from app.services.operation_log import log_operation
-from app.utils.id_gen import generate_id
+from app.services.credit.product_service import product_to_dict
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
-def _to_out(pkg: RechargePackage) -> dict:
- return {
- "id": pkg.id,
- "name": pkg.name,
- "credits": round(pkg.credits, 2),
- "price": round(pkg.price, 2),
- "bonus_credits": round(pkg.bonus_credits, 2),
- "total_credits": round(pkg.credits + pkg.bonus_credits, 2),
- "description": pkg.description,
- "package_type": pkg.package_type,
- "is_gift": pkg.is_gift,
- "is_active": pkg.is_active,
- "sort_order": pkg.sort_order,
- }
-
-
-@router.get("", response_model=list[RechargePackageOut])
+@router.get("")
async def admin_list_packages(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
- select(RechargePackage).order_by(RechargePackage.sort_order)
+ select(CreditProduct)
+ .where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
+ .order_by(CreditProduct.sort_order, CreditProduct.id)
)
- return [_to_out(p) for p in result.scalars().all()]
-
-
-@router.post("", response_model=RechargePackageOut)
-async def create_package(
- data: RechargePackageCreate,
- admin: User = Depends(get_admin_user),
- db: AsyncSession = Depends(get_db),
-):
- pkg = RechargePackage(id=generate_id(), **data.model_dump())
- db.add(pkg)
- await db.flush()
- await log_operation(
- db,
- admin.id,
- admin.username,
- f"创建充值套餐 {pkg.name}",
- "POST",
- "/admin/recharge-packages",
- detail=json.dumps(
- {
- "id": pkg.id,
- "name": pkg.name,
- "price": round(pkg.price, 2),
- "credits": round(pkg.credits, 2),
- "bonus_credits": round(pkg.bonus_credits, 2),
- },
- ensure_ascii=False,
- ),
- )
- await db.commit()
- return _to_out(pkg)
-
-
-@router.put("/{pkg_id}", response_model=RechargePackageOut)
-async def update_package(
- pkg_id: str,
- data: RechargePackageUpdate,
- admin: User = Depends(get_admin_user),
- db: AsyncSession = Depends(get_db),
-):
- result = await db.execute(
- select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
- )
- pkg = result.scalar_one_or_none()
- if not pkg:
- raise HTTPException(status_code=404, detail="套餐不存在")
- before = _to_out(pkg)
- for k, v in data.model_dump(exclude_unset=True).items():
- setattr(pkg, k, v)
- await db.flush()
- after = _to_out(pkg)
- await log_operation(
- db,
- admin.id,
- admin.username,
- f"更新充值套餐 {pkg.name}",
- "PUT",
- f"/admin/recharge-packages/{pkg_id}",
- detail=json.dumps(
- {
- "id": pkg_id,
- "before": before,
- "after": after,
- },
- ensure_ascii=False,
- ),
- )
- await db.commit()
- return _to_out(pkg)
-
-
-@router.delete("/{pkg_id}")
-async def delete_package(
- pkg_id: str,
- admin: User = Depends(get_admin_user),
- db: AsyncSession = Depends(get_db),
-):
- result = await db.execute(
- select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
- )
- pkg = result.scalar_one_or_none()
- if not pkg:
- raise HTTPException(status_code=404, detail="套餐不存在")
- pkg_name = pkg.name
- await db.delete(pkg)
- await db.flush()
- await log_operation(
- db,
- admin.id,
- admin.username,
- f"删除充值套餐 {pkg_name}",
- "DELETE",
- f"/admin/recharge-packages/{pkg_id}",
- detail=json.dumps(
- {
- "id": pkg_id,
- "name": pkg_name,
- },
- ensure_ascii=False,
- ),
- )
- await db.commit()
- return {"ok": True}
+ return [product_to_dict(item) for item in result.scalars().all()]
diff --git a/video-gen-api/app/api/v1/__init__.py b/video-gen-api/app/api/v1/__init__.py
index bd89d179..e93159c3 100644
--- a/video-gen-api/app/api/v1/__init__.py
+++ b/video-gen-api/app/api/v1/__init__.py
@@ -12,6 +12,7 @@ from app.api.v1.sms import router as sms_router
from app.api.v1.industries import router as industries_router
from app.api.v1.menu_configs import router as menu_configs_router
from app.api.v1.recharge_packages import router as recharge_packages_router
+from app.api.v1.credit_products import router as credit_products_router
from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
@@ -49,6 +50,7 @@ api_router.include_router(sms_router)
api_router.include_router(industries_router)
api_router.include_router(menu_configs_router)
api_router.include_router(recharge_packages_router)
+api_router.include_router(credit_products_router)
api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py
index c2f37871..a6a16e93 100644
--- a/video-gen-api/app/api/v1/admin.py
+++ b/video-gen-api/app/api/v1/admin.py
@@ -48,10 +48,13 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
+from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
+from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
+from app.services.credit.time_policy import add_natural_months
+from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
-from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
@@ -130,6 +133,9 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
+ credit_map = await get_user_credit_map(db, user_ids)
+ for item in users:
+ attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
AdminUserOut.model_validate(user)
@@ -178,16 +184,29 @@ async def create_user(
hashed_password=hash_password(req.password),
email=req.email,
phone=req.phone,
- credits=req.credits,
is_admin=req.is_admin if req.user_type == "admin" else False,
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_asset_limit=req.private_portrait_asset_limit,
)
- user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
+ if req.credits > 0:
+ now = utc_now()
+ await add_credits(
+ db, user.id, req.credits, "管理员创建用户初始积分",
+ record_meta=build_admin_adjust_meta(),
+ valid_from=now,
+ expires_at=add_natural_months(now, 1),
+ credit_level=CreditLevel.GENERAL.value,
+ source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
+ source_id=admin.id,
+ related_id=admin.id,
+ biz_key=f"admin-create-user-credit:{user.id}",
+ )
+ else:
+ attach_credit_snapshot(user, 0)
await log_operation(
db,
admin.id,
@@ -257,7 +276,7 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
- user.credits = round(user.credits, 2)
+ attach_credit_snapshot(user, await get_available_credits(db, user.id))
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
@@ -276,7 +295,17 @@ async def adjust_credits(
db: AsyncSession = Depends(get_db),
):
if req.amount > 0:
- await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
+ now = utc_now()
+ await add_credits(
+ db, user_id, req.amount, f"管理员调整: {req.description}",
+ record_meta=build_admin_adjust_meta(),
+ valid_from=now, expires_at=add_natural_months(now, 1),
+ credit_level=CreditLevel.GENERAL.value,
+ source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
+ source_id=admin.id,
+ related_id=admin.id,
+ biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
+ )
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await create_notification(
@@ -1526,6 +1555,8 @@ async def create_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
+ if req.provider == "mock":
+ raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
@@ -1554,6 +1585,8 @@ async def update_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
+ if req.provider == "mock":
+ raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
@@ -1629,10 +1662,6 @@ async def create_system_config(
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
- try:
- await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
@@ -1667,10 +1696,6 @@ async def update_system_config(
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
- try:
- await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value)
await db.flush()
await log_operation(
diff --git a/video-gen-api/app/api/v1/auth.py b/video-gen-api/app/api/v1/auth.py
index b68a7ea3..6397329c 100644
--- a/video-gen-api/app/api/v1/auth.py
+++ b/video-gen-api/app/api/v1/auth.py
@@ -12,7 +12,6 @@ from app.dependencies import (
get_current_user_allow_password_pending,
get_db,
)
-from app.models.credit_record import CreditRecord
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
@@ -34,6 +33,10 @@ from app.services.auth import (
)
from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage
+from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
+from app.services.credit.ledger_service import grant_credits
+from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
+from app.services.credit.time_policy import add_natural_months
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -93,50 +96,57 @@ async def _get_register_credits(db: AsyncSession) -> int:
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
if credits <= 0:
+ attach_credit_snapshot(user, 0)
return
- record = CreditRecord(
- id=generate_id(),
+ now = datetime.now(timezone.utc)
+ result = await grant_credits(
+ db,
user_id=user.id,
- type="recharge",
amount=credits,
- balance_after=user.credits,
description=f"注册赠送 {credits} 积分",
+ source_type=CreditBalanceSourceType.REGISTER_GIFT.value,
+ source_id=user.id,
+ valid_from=now,
+ expires_at=add_natural_months(now, 1),
+ credit_level=CreditLevel.PROMOTIONAL.value,
+ related_id=user.id,
+ biz_key=f"register-gift:{user.id}",
+ request_time=now,
)
- db.add(record)
+ attach_credit_snapshot(user, result.balance_after)
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
enabled_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
)
- enabled = enabled_result.scalar_one_or_none() == "true"
- if not enabled:
+ if enabled_result.scalar_one_or_none() != "true":
+ attach_credit_snapshot(user, await get_available_credits(db, user.id))
return
-
credits_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits").limit(1)
)
credits = int(credits_result.scalar_one_or_none() or "0")
- if credits <= 0:
- return
-
- today = datetime.now(CST).date()
- if user.last_login_at:
- last_login_date = user.last_login_at.date()
- if last_login_date >= today:
- return
-
- user.credits += credits
-
- record = CreditRecord(
- id=generate_id(),
- user_id=user.id,
- type="recharge",
- amount=credits,
- balance_after=user.credits,
- description=f"每日登录赠送 {credits} 积分",
- )
- db.add(record)
+ now_cst = datetime.now(CST)
+ if credits > 0:
+ next_midnight_cst = datetime.combine(now_cst.date() + timedelta(days=1), datetime.min.time(), tzinfo=CST)
+ result = await grant_credits(
+ db,
+ user_id=user.id,
+ amount=credits,
+ description=f"每日登录赠送 {credits} 积分",
+ source_type=CreditBalanceSourceType.DAILY_LOGIN.value,
+ source_id=now_cst.date().isoformat(),
+ valid_from=now_cst,
+ expires_at=next_midnight_cst,
+ credit_level=CreditLevel.PROMOTIONAL.value,
+ related_id=user.id,
+ biz_key=f"daily-login:{user.id}:{now_cst.date().isoformat()}",
+ request_time=now_cst,
+ )
+ attach_credit_snapshot(user, result.balance_after)
+ else:
+ attach_credit_snapshot(user, await get_available_credits(db, user.id))
@router.post(
@@ -226,7 +236,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
phone=req.phone,
hashed_password=hash_password(req.password),
password_set_at=datetime.now(CST),
- credits=register_credits,
is_admin=False,
user_type="frontend",
)
@@ -342,7 +351,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
- "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
+ "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
]))
)
configs = result.scalars().all()
@@ -366,7 +375,6 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"site_copyright": info.get("site_copyright", "© 2026 智创 版权所有"),
"operation_manual": info.get("operation_manual", ""),
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
- "optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
}
diff --git a/video-gen-api/app/api/v1/credit_products.py b/video-gen-api/app/api/v1/credit_products.py
new file mode 100644
index 00000000..04a39723
--- /dev/null
+++ b/video-gen-api/app/api/v1/credit_products.py
@@ -0,0 +1,17 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.dependencies import get_current_user, get_db
+from app.models.user import User
+from app.schemas.credit_product import CreditProductCatalogOut
+from app.services.credit.product_service import build_product_catalog
+
+router = APIRouter(prefix="/credit-products", tags=["credit-products"])
+
+
+@router.get("/catalog", response_model=CreditProductCatalogOut)
+async def get_credit_product_catalog(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ return await build_product_catalog(db, user=current_user)
diff --git a/video-gen-api/app/api/v1/credits.py b/video-gen-api/app/api/v1/credits.py
index ab893508..fee34b36 100644
--- a/video-gen-api/app/api/v1/credits.py
+++ b/video-gen-api/app/api/v1/credits.py
@@ -1,20 +1,53 @@
from fastapi import APIRouter, Depends, Query
+from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
-from app.dependencies import get_db, get_current_user
-from app.models.user import User
+from app.dependencies import get_current_user, get_db
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit_record import CreditRecord
from app.models.credit_ratio import CreditRatio
-from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
-from app.schemas.credit import CreditBalanceOut, CreditRecordOut
+from app.models.user import User
+from app.models.video_engine import VideoEngine
+from app.schemas.credit import CreditRecordOut
+from app.schemas.credit_balance import CreditBalanceItemOut
from app.schemas.credit_ratio import CreditRatioOut
+from app.services.credit.query_service import (
+ apply_balance_status_filter,
+ effective_balance_status,
+ get_balance_summary,
+)
+from app.services.credit.utils import utc_now
+from app.services.credit.time_policy import last_usable_at
from app.services.credit_ratio_service import list_all_credit_ratios
from app.services.credits import get_records
router = APIRouter(prefix="/credits", tags=["credits"])
+def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
+ return CreditBalanceItemOut(
+ id=item.id,
+ credit_level=item.credit_level,
+ source_type=item.source_type,
+ source_id=item.source_id,
+ product_id=item.product_id,
+ payment_order_id=item.payment_order_id,
+ subscription_id=item.subscription_id,
+ subscription_period_id=item.subscription_period_id,
+ grant_amount=float(item.grant_amount),
+ unspent_amount=float(item.unspent_amount),
+ consumed_amount=float(item.consumed_amount),
+ expired_amount=float(item.expired_amount),
+ revoked_amount=float(item.revoked_amount),
+ valid_from=item.valid_from,
+ expires_at=item.expires_at,
+ last_usable_at=last_usable_at(item.expires_at),
+ status=effective_balance_status(item, request_time=checked_at),
+ created_at=item.created_at,
+ )
+
+
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -23,18 +56,47 @@ async def get_credits(
db: AsyncSession = Depends(get_db),
):
records, total = await get_records(db, current_user.id, page, page_size)
+ summary = await get_balance_summary(db, current_user.id)
+ totals_result = await db.execute(
+ select(
+ func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
+ func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
+ func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
+ func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0),
+ ).where(CreditRecord.user_id == current_user.id)
+ )
+ total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
- "credits": round(current_user.credits, 2),
+ **summary.to_dict(),
"records": [CreditRecordOut.model_validate(r) for r in records],
"total": total,
+ "total_granted": float(total_granted or 0),
+ "total_consumed": float(total_consumed or 0),
+ "total_refunded": float(total_refunded or 0),
+ "total_expired": float(total_expired or 0),
}
+@router.get("/balances")
+async def list_credit_balances(
+ status: str | None = Query(default=None),
+ page: int = Query(1, ge=1),
+ page_size: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ checked_at = utc_now()
+ stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
+ stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
+ stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
+ result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
+ return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()]
+
+
@router.get(
"/credit-ratios",
response_model=list[CreditRatioOut],
summary="获取积分比例列表",
- description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
)
async def list_client_credit_ratios(
current_user: User = Depends(get_current_user),
@@ -59,29 +121,22 @@ async def get_credit_ratios(
if ratios:
return [CreditRatioOut.model_validate(r) for r in ratios]
return []
-
- video_engines_result = await db.execute(
+
+ video_result = await db.execute(
select(VideoEngine.id)
- .where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
+ .where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
)
- video_engine_ids = video_engines_result.scalars().all()
-
- image_engines_result = await db.execute(
+ image_result = await db.execute(
select(ImageEngine.id)
- .where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
+ .where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
- image_engine_ids = image_engines_result.scalars().all()
-
grouped = {}
-
- video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
+ video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all()))
+ image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all()))
if video_ratios:
grouped["video"] = video_ratios
-
- image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
if image_ratios:
grouped["image"] = image_ratios
-
return grouped
diff --git a/video-gen-api/app/api/v1/hot_opening_replicate.py b/video-gen-api/app/api/v1/hot_opening_replicate.py
index 3369ea51..57c0dced 100644
--- a/video-gen-api/app/api/v1/hot_opening_replicate.py
+++ b/video-gen-api/app/api/v1/hot_opening_replicate.py
@@ -17,7 +17,6 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
@@ -36,7 +35,6 @@ from app.schemas.hot_opening_replicate import (
)
from app.services.hot_opening_replicate_service import (
_get_project_for_user,
- create_hot_opening_project,
delete_hot_opening_project,
generate_image_from_prompt,
generate_video_from_prompt,
@@ -66,8 +64,8 @@ from app.services.module_async_recovery_service import (
remove_active_task,
)
from app.tasks.celery_app import celery_app
-from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
-from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
+from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
+from app.services.upload_resource import upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -181,12 +179,7 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
- hold_config_key=(
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
- if is_image
- else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
- ),
- description_prefix=(
+ description_prefix=(
"爆款开头复刻图片AI提词优化"
if is_image
else "爆款开头复刻视频提词优化"
diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py
index 60c12a77..c867bdbc 100644
--- a/video-gen-api/app/api/v1/payments.py
+++ b/video-gen-api/app/api/v1/payments.py
@@ -9,17 +9,16 @@ logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
-from app.models.recharge_package import RechargePackage
+from app.models.credit.product import CreditProduct
+from app.services.credit.upgrade_service import release_upgrade_reservation
+from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
create_recharge_order,
- verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
- process_refund,
_get_payment_configs,
_close_alipay_order,
- _get_order_expire_seconds,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -57,27 +56,23 @@ async def recharge(
raise HTTPException(status_code=400, detail="该支付方式未启用")
result = await db.execute(
- select(RechargePackage).where(
- RechargePackage.id == req.plan,
- RechargePackage.is_active == True,
- )
- .limit(1)
+ select(CreditProduct).where(
+ CreditProduct.id == req.plan,
+ CreditProduct.is_active.is_(True),
+ ).limit(1)
)
- pkg = result.scalar_one_or_none()
- if not pkg:
- raise HTTPException(status_code=400, detail="无效的套餐")
+ product = result.scalar_one_or_none()
+ if not product:
+ raise HTTPException(status_code=400, detail="无效或已下架的积分商品")
try:
order = await create_recharge_order(
db,
current_user.id,
- credits=pkg.credits,
- price=pkg.price,
- label=pkg.name,
- bonus_credits=pkg.bonus_credits,
method=req.method,
+ product_id=product.id,
)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
return order
@@ -86,11 +81,11 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
# 读取微信支付回调数据
body_bytes = await request.body()
body_str = body_bytes.decode("utf-8")
-
+
# 获取配置
from app.services.payment import _get_payment_configs, _is_mock_mode, _get_wechat_client
db_configs = await _get_payment_configs(db)
-
+
# 检查 mock 模式
if _is_mock_mode(db_configs):
try:
@@ -104,29 +99,29 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.exception(f"Mock WeChat callback error: {e}")
return {"code": "SUCCESS", "message": "OK"} # 微信要求即使处理失败也返回成功
-
+
# 真实模式:使用 wechatpayv3 SDK 工具验证回调并解析数据
try:
from wechatpayv3.utils import (
rsa_verify, load_public_key, sha256, b64decode,
AESGCM, InvalidTag
)
-
+
mch_id = db_configs.get("payment_wechat_mch_id", "")
api_v3_key = db_configs.get("payment_wechat_api_v3_key", "")
public_key = db_configs.get("payment_wechat_public_key", "")
-
+
if not all([mch_id, api_v3_key]):
logger.error("WeChat payment config missing for callback")
return {"code": "SUCCESS", "message": "OK"}
-
+
# 从请求头获取必要信息(不区分大小写)
headers = {k.lower(): v for k, v in dict(request.headers).items()}
timestamp = headers.get("wechatpay-timestamp", "")
nonce = headers.get("wechatpay-nonce", "")
signature = headers.get("wechatpay-signature", "")
serial_no = headers.get("wechatpay-serial", "")
-
+
# 安全要求:非mock模式下必须验证签名,配置缺失直接拒绝
if not public_key:
logger.error("WeChat platform public key not configured, cannot verify callback signature")
@@ -137,7 +132,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
if not timestamp or not nonce or not signature:
logger.error("WeChat callback missing required signature headers")
return {"code": "FAIL", "message": "Missing signature headers"}
-
+
# 验证签名:使用平台公钥验证
try:
is_verified = rsa_verify(
@@ -153,27 +148,27 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.warning(f"WeChat signature verification error: {e}, serial={serial_no}")
return {"code": "FAIL", "message": "Signature verification error"}
-
+
# 解密回调数据:使用 API v3 key
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
import json
body_data = json.loads(body_str) if body_str else {}
resource = body_data.get("resource", {})
-
+
if not resource:
logger.error("WeChat callback resource not found")
raise HTTPException(status_code=400, detail="数据格式错误")
-
+
# 验证加密算法(官方文档要求固定为 AEAD_AES_256_GCM)
algorithm = resource.get("algorithm", "")
if algorithm != "AEAD_AES_256_GCM":
logger.error(f"WeChat callback unsupported algorithm: {algorithm}")
raise HTTPException(status_code=400, detail="不支持的加密算法")
-
+
ciphertext = resource.get("ciphertext", "")
associated_data = resource.get("associated_data", "")
nonce_str = resource.get("nonce", "")
-
+
# 参数验证
if not ciphertext:
logger.error("WeChat callback ciphertext is empty")
@@ -181,22 +176,22 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
if not nonce_str:
logger.error("WeChat callback nonce is empty")
raise HTTPException(status_code=400, detail="随机数为空")
-
+
# 使用 AES-GCM 解密(符合官方文档规范)
# 官方文档:https://pay.weixin.qq.com/doc/v3/merchant/4012071382
try:
# API v3 key 需要转换为字节串
api_v3_key_bytes = api_v3_key.encode('utf-8')
-
+
# ciphertext 是 Base64 编码的,需要解码
ciphertext_bytes = b64decode(ciphertext)
-
+
# nonce 直接使用字符串编码(官方文档方式)
nonce_bytes = nonce_str.encode('utf-8')
-
+
# associated_data 是字符串,直接编码
associated_data_bytes = associated_data.encode('utf-8') if associated_data else b''
-
+
aesgcm = AESGCM(api_v3_key_bytes)
decrypted_str = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_data_bytes)
except InvalidTag:
@@ -205,53 +200,53 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
except Exception as e:
logger.error(f"WeChat callback decryption failed: {e}")
raise HTTPException(status_code=400, detail="数据解密失败")
-
+
if not decrypted_str:
logger.error("WeChat callback decryption returned empty")
raise HTTPException(status_code=400, detail="数据解密失败")
-
+
decrypted_data = json.loads(decrypted_str)
-
+
event_type = body_data.get("event_type", "")
-
+
# 处理支付成功回调
if event_type == "TRANSACTION.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
transaction_id = decrypted_data.get("transaction_id", "")
amount_info = decrypted_data.get("amount", {})
total_amount = amount_info.get("total", 0) / 100 # 转换为元
-
+
if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
-
+
# 处理退款回调
elif event_type == "REFUND.SUCCESS":
order_no = decrypted_data.get("out_trade_no", "")
refund_id = decrypted_data.get("refund_id", "")
refund_status = decrypted_data.get("status", "")
-
+
if order_no and refund_status == "SUCCESS":
# 更新订单状态为已退款
from app.models import PaymentOrder
from sqlalchemy import select
-
+
result = await db.execute(select(PaymentOrder).where(PaymentOrder.order_no == order_no))
order = result.scalar_one_or_none()
-
+
if order and order.status == "refunding":
order.status = "refunded"
order.transaction_id = refund_id
await db.commit()
-
+
logger.info(
f"WeChat refund callback processed: order_no={order_no}, "
f"refund_id={refund_id}, status={refund_status}"
)
-
+
return {"code": "SUCCESS", "message": "OK"}
except Exception as e:
logger.exception(f"WeChat callback processing error: {e}")
@@ -263,7 +258,7 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
form_data = await request.form()
data = dict(form_data)
-
+
logger.info(
f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
f"data={data}"
@@ -283,7 +278,7 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None
-
+
if order_no:
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
@@ -350,14 +345,14 @@ async def cancel_order(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
- ).limit(1)
+ ).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
-
+
# If it's an Alipay or WeChat order, call close API first
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
@@ -371,8 +366,10 @@ async def cancel_order(
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
-
+
order.status = "cancelled"
+ if order.upgrade_period_ids_json:
+ await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
diff --git a/video-gen-api/app/api/v1/recharge_packages.py b/video-gen-api/app/api/v1/recharge_packages.py
index a6d95b14..9e1e31c8 100644
--- a/video-gen-api/app/api/v1/recharge_packages.py
+++ b/video-gen-api/app/api/v1/recharge_packages.py
@@ -1,39 +1,41 @@
+"""Legacy endpoint retained for old clients; only active credit add-ons are returned."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.dependencies import get_db, get_current_user
+from app.dependencies import get_current_user, get_db
+from app.enums.credit_product import CreditProductType
+from app.models.credit.product import CreditProduct
from app.models.user import User
-from app.models.recharge_package import RechargePackage
+from app.services.credit.product_service import product_to_dict
router = APIRouter(tags=["recharge-packages"])
-def _to_out(pkg: RechargePackage) -> dict:
- return {
- "id": pkg.id,
- "name": pkg.name,
- "credits": round(pkg.credits, 2),
- "price": round(pkg.price, 2),
- "bonus_credits": round(pkg.bonus_credits, 2),
- "total_credits": round(pkg.credits + pkg.bonus_credits, 2),
- "description": pkg.description,
- "package_type": pkg.package_type,
- "is_gift": pkg.is_gift,
- "is_active": pkg.is_active,
- "sort_order": pkg.sort_order,
- }
-
-
@router.get("/recharge-packages")
async def list_active_packages(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Public: list active recharge packages."""
result = await db.execute(
- select(RechargePackage)
- .where(RechargePackage.is_active == True)
- .order_by(RechargePackage.sort_order)
+ select(CreditProduct)
+ .where(
+ CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
+ CreditProduct.is_active.is_(True),
+ )
+ .order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
- return [_to_out(p) for p in result.scalars().all()]
+ items = []
+ for product in result.scalars().all():
+ payload = product_to_dict(product, user_price=product.price, price_type="regular", can_purchase=True)
+ payload.update(
+ {
+ "credits": payload["grant_credits"],
+ "bonus_credits": 0.0,
+ "total_credits": payload["grant_credits"],
+ "package_type": "credit_addon",
+ "is_gift": False,
+ }
+ )
+ items.append(payload)
+ return items
diff --git a/video-gen-api/app/api/v1/shot_replicate.py b/video-gen-api/app/api/v1/shot_replicate.py
index 2ca75228..bb41bbbd 100644
--- a/video-gen-api/app/api/v1/shot_replicate.py
+++ b/video-gen-api/app/api/v1/shot_replicate.py
@@ -20,7 +20,6 @@ from app.enums.credit_record import (
CreditRecordOwnerType,
CreditRecordSourceStepCode,
)
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -235,12 +234,7 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
- hold_config_key=(
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
- if is_image
- else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
- ),
- description_prefix=(
+ description_prefix=(
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
),
trace_id=f"shot-replicate-prompt:{step_id}:attempt:{attempt_no}",
@@ -277,8 +271,7 @@ def _analysis_dispatch_billing_context(
source_step_id=owner_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=owner_id,
- hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- description_prefix=(
+ description_prefix=(
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
),
trace_id=f"shot-analysis:{owner_id}:attempt:{attempt_no}",
diff --git a/video-gen-api/app/api/v1/team.py b/video-gen-api/app/api/v1/team.py
index 88cde7c2..abbf8800 100644
--- a/video-gen-api/app/api/v1/team.py
+++ b/video-gen-api/app/api/v1/team.py
@@ -9,10 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
-from app.config import settings
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.models.team import Team
-from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
@@ -23,17 +21,12 @@ from app.schemas.team_join_request import (
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
- ManagedTeamOut,
ManagerTransferRequest,
- SetManagerRequest,
- TeamMemberOut,
)
from app.services import team_invitation_service
from app.services.team_manager_service import (
get_managed_team,
get_team_members,
- is_team_manager,
- transfer_credits_to_member,
)
router = APIRouter(prefix="/team", tags=["team"])
@@ -92,15 +85,7 @@ async def transfer_credits(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- await transfer_credits_to_member(
- db,
- current_user.id,
- req.target_user_id,
- req.amount,
- req.direction or "increase",
- req.description,
- )
- return {"message": "ok"}
+ raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
# ── 邀请码管理 ────────────────────────────────────────
diff --git a/video-gen-api/app/cmd/migrate_legacy_llm_holds.py b/video-gen-api/app/cmd/migrate_legacy_llm_holds.py
new file mode 100644
index 00000000..3a34b7cc
--- /dev/null
+++ b/video-gen-api/app/cmd/migrate_legacy_llm_holds.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+import asyncio
+import json
+
+from sqlalchemy import text
+
+from app.models.base import async_session
+from app.services.credit.utils import utc_now
+
+
+_UNRESOLVED_HOLDS_SQL = text(
+ """
+ SELECT
+ h.id,
+ h.user_id,
+ h.biz_key,
+ h.amount,
+ h.created_at
+ FROM credit_records AS h
+ WHERE h.type = 'hold'
+ AND NOT EXISTS (
+ SELECT 1
+ FROM credit_records AS x
+ WHERE x.refund_for_biz_key = h.biz_key
+ OR x.biz_key IN (
+ REPLACE(h.biz_key, :hold_suffix, :hold_release_suffix),
+ REPLACE(h.biz_key, :hold_suffix, :charge_suffix)
+ )
+ )
+ ORDER BY h.created_at ASC
+ LIMIT 5000
+ """
+)
+
+
+async def amain() -> int:
+ """维护窗口检查脚本:列出旧 HOLD 未形成 RELEASE/CHARGE 的业务,禁止带病切换。"""
+ async with async_session() as db:
+ result = await db.execute(
+ _UNRESOLVED_HOLDS_SQL,
+ {
+ "hold_suffix": ":hold",
+ "hold_release_suffix": ":hold_release",
+ "charge_suffix": ":charge",
+ },
+ )
+ rows = result.mappings().all()
+
+ print(
+ json.dumps(
+ {
+ "checked_at": utc_now().isoformat(),
+ "unresolved_count": len(rows),
+ "items": [dict(row) for row in rows],
+ },
+ ensure_ascii=False,
+ indent=2,
+ default=str,
+ )
+ )
+ return 0 if not rows else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(amain()))
diff --git a/video-gen-api/app/cmd/migrate_user_credits.py b/video-gen-api/app/cmd/migrate_user_credits.py
new file mode 100644
index 00000000..a436a78a
--- /dev/null
+++ b/video-gen-api/app/cmd/migrate_user_credits.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+import json
+import sys
+from decimal import Decimal
+
+from sqlalchemy import text
+
+from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
+from app.enums.credit_record import CreditRecordType
+from app.models.base import async_session
+from app.services.credit.ledger_service import grant_credits
+from app.services.credit.time_policy import add_natural_months
+from app.services.credit.utils import to_credit_decimal, utc_now
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="将 users.credits 一次性迁移到动态积分余额表。")
+ parser.add_argument("--batch-size", type=int, default=500)
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--commit", action="store_true")
+ parser.add_argument("--after-id", default="")
+ return parser
+
+
+async def amain(argv: list[str]) -> int:
+ args = build_parser().parse_args(argv)
+ if args.dry_run and args.commit:
+ raise SystemExit("--dry-run 和 --commit 不能同时使用")
+ do_commit = bool(args.commit)
+ batch_size = max(1, min(int(args.batch_size or 500), 5000))
+ migration_time = utc_now()
+ cursor = str(args.after_id or "")
+ stats = {"positive_users": 0, "zero_users": 0, "negative_reset_users": 0, "migrated_credits": "0.00", "last_id": cursor}
+ total = Decimal("0.00")
+
+ while True:
+ async with async_session() as db:
+ rows = (await db.execute(
+ text("SELECT id, credits FROM users WHERE id > :cursor ORDER BY id ASC LIMIT :limit"),
+ {"cursor": cursor, "limit": batch_size},
+ )).mappings().all()
+ if not rows:
+ break
+ try:
+ for row in rows:
+ user_id = str(row["id"])
+ legacy = to_credit_decimal(row.get("credits") or 0)
+ cursor = user_id
+ stats["last_id"] = cursor
+ if legacy > 0:
+ stats["positive_users"] += 1
+ total += legacy
+ if do_commit:
+ await grant_credits(
+ db,
+ user_id=user_id,
+ amount=legacy,
+ description="历史用户积分迁移",
+ source_type=CreditBalanceSourceType.LEGACY_MIGRATION.value,
+ valid_from=migration_time,
+ expires_at=add_natural_months(migration_time, 1),
+ credit_level=CreditLevel.GENERAL.value,
+ source_id=user_id,
+ related_id=user_id,
+ record_type=CreditRecordType.RECHARGE.value,
+ biz_key=f"legacy-user-credits:{user_id}",
+ metadata_json={"legacy_credits": str(legacy), "migration_time": migration_time.isoformat()},
+ request_time=migration_time,
+ )
+ elif legacy < 0:
+ stats["negative_reset_users"] += 1
+ else:
+ stats["zero_users"] += 1
+ if do_commit:
+ await db.commit()
+ else:
+ await db.rollback()
+ except Exception:
+ await db.rollback()
+ raise
+ stats["migrated_credits"] = str(total.quantize(Decimal("0.01")))
+ stats["migration_time"] = migration_time.isoformat()
+ stats["mode"] = "commit" if do_commit else "dry-run"
+ print(json.dumps(stats, ensure_ascii=False, indent=2))
+ return 0
+
+
+def main() -> int:
+ return asyncio.run(amain(sys.argv[1:]))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/video-gen-api/app/cmd/reconcile_llm_billing.py b/video-gen-api/app/cmd/reconcile_llm_billing.py
new file mode 100644
index 00000000..a3e0513b
--- /dev/null
+++ b/video-gen-api/app/cmd/reconcile_llm_billing.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import asyncio
+import json
+
+from sqlalchemy import text
+
+from app.models.base import async_session
+from app.services.credit.utils import utc_now
+
+
+async def amain() -> int:
+ async with async_session() as db:
+ rows = (await db.execute(text("""
+ SELECT e.id, e.total_call_count, e.successful_call_count, e.failed_call_count,
+ e.total_input_tokens, e.total_output_tokens, e.total_tokens,
+ COUNT(a.id) AS actual_calls,
+ COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS actual_success,
+ COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0) AS actual_failed,
+ COALESCE(SUM(a.input_tokens), 0) AS actual_input,
+ COALESCE(SUM(a.output_tokens), 0) AS actual_output,
+ COALESCE(SUM(a.total_tokens), 0) AS actual_total
+ FROM llm_billing_executions e
+ LEFT JOIN llm_call_attempts a ON a.billing_execution_id = e.id
+ GROUP BY e.id
+ HAVING e.total_call_count <> COUNT(a.id)
+ OR e.successful_call_count <> COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0)
+ OR e.failed_call_count <> COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0)
+ OR e.total_tokens <> COALESCE(SUM(a.total_tokens), 0)
+ ORDER BY e.id
+ LIMIT 1000
+ """))).mappings().all()
+ print(json.dumps({"checked_at": utc_now().isoformat(), "mismatch_count": len(rows), "items": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str))
+ return 0 if not rows else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(amain()))
diff --git a/video-gen-api/app/cmd/reconcile_user_credits.py b/video-gen-api/app/cmd/reconcile_user_credits.py
new file mode 100644
index 00000000..238b931e
--- /dev/null
+++ b/video-gen-api/app/cmd/reconcile_user_credits.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from decimal import Decimal
+
+from sqlalchemy import text
+
+from app.models.base import async_session
+from app.services.credit.utils import utc_now
+
+
+async def amain() -> int:
+ checked_at = utc_now()
+ async with async_session() as db:
+ row = (await db.execute(text("""
+ SELECT
+ COALESCE(SUM(CASE WHEN credits > 0 THEN credits ELSE 0 END), 0) AS legacy_positive,
+ COALESCE((SELECT SUM(grant_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_grant,
+ COALESCE((SELECT SUM(unspent_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_unspent,
+ COALESCE((SELECT COUNT(*) FROM users WHERE credits < 0), 0) AS legacy_negative_users
+ FROM users
+ """))).mappings().one()
+ legacy = Decimal(str(row["legacy_positive"] or 0)).quantize(Decimal("0.01"))
+ migrated = Decimal(str(row["migrated_grant"] or 0)).quantize(Decimal("0.01"))
+ output = {
+ "checked_at": checked_at.isoformat(),
+ "legacy_positive": str(legacy),
+ "migrated_grant": str(migrated),
+ "migrated_unspent": str(Decimal(str(row["migrated_unspent"] or 0)).quantize(Decimal("0.01"))),
+ "legacy_negative_users_reset_to_zero": int(row["legacy_negative_users"] or 0),
+ "matched": legacy == migrated,
+ }
+ print(json.dumps(output, ensure_ascii=False, indent=2))
+ return 0 if output["matched"] else 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(amain()))
diff --git a/video-gen-api/app/dependencies.py b/video-gen-api/app/dependencies.py
index 7299a4a5..6ff0b397 100644
--- a/video-gen-api/app/dependencies.py
+++ b/video-gen-api/app/dependencies.py
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.user import User
from app.services.auth import decode_access_token, user_must_set_password
+from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
security = HTTPBearer(auto_error=False)
@@ -53,6 +54,7 @@ async def get_current_user_allow_password_pending(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在或已禁用",
)
+ attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
@@ -92,6 +94,7 @@ async def get_optional_current_user(
if user_must_set_password(user):
return None
+ attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
diff --git a/video-gen-api/app/enums/celery_queue.py b/video-gen-api/app/enums/celery_queue.py
index 47242ef9..edb25e14 100644
--- a/video-gen-api/app/enums/celery_queue.py
+++ b/video-gen-api/app/enums/celery_queue.py
@@ -11,6 +11,7 @@ class CeleryQueue(str, Enum):
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
GEN_SHOT_SPLIT = "gen_shot_split"
+ GEN_CREDIT_MAINTENANCE = "gen_credit_maintenance"
DEFAULT = "default"
@@ -43,3 +44,4 @@ class CeleryTaskName(str, Enum):
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
+ CREDIT_MAINTENANCE = "credit.maintenance_once"
diff --git a/video-gen-api/app/enums/credit_balance.py b/video-gen-api/app/enums/credit_balance.py
new file mode 100644
index 00000000..246ce476
--- /dev/null
+++ b/video-gen-api/app/enums/credit_balance.py
@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class CreditLevel(StrEnum):
+ PROMOTIONAL = "promotional"
+ GENERAL = "general"
+
+
+CREDIT_LEVEL_SORT = {
+ CreditLevel.PROMOTIONAL.value: 10,
+ CreditLevel.GENERAL.value: 20,
+}
+
+
+class CreditBalanceSourceType(StrEnum):
+ REGISTER_GIFT = "register_gift"
+ DAILY_LOGIN = "daily_login"
+ SIGN_IN = "sign_in"
+ ACTIVITY = "activity"
+ ADMIN_GRANT = "admin_grant"
+ SUBSCRIPTION_GRANT = "subscription_grant"
+ CREDIT_ADDON = "credit_addon"
+ LEGACY_MIGRATION = "legacy_migration"
+ BUSINESS_REFUND = "business_refund"
+
+
+class CreditBalanceStatus(StrEnum):
+ SCHEDULED = "scheduled"
+ ACTIVE = "active"
+ CONSUMED = "consumed"
+ EXPIRED = "expired"
+ REVOKED = "revoked"
+ CANCELLED = "cancelled"
+
+
+class CreditAllocationAction(StrEnum):
+ GRANT = "grant"
+ CONSUME = "consume"
+ REFUND_AVAILABLE = "refund_available"
+ REFUND_EXPIRED = "refund_expired"
+ EXPIRE = "expire"
+ REVOKE = "revoke"
+ UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
+ UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
diff --git a/video-gen-api/app/enums/credit_product.py b/video-gen-api/app/enums/credit_product.py
new file mode 100644
index 00000000..3e89603b
--- /dev/null
+++ b/video-gen-api/app/enums/credit_product.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class CreditProductType(StrEnum):
+ SUBSCRIPTION = "subscription"
+ CREDIT_ADDON = "credit_addon"
+
+
+class SubscriptionBillingCycle(StrEnum):
+ MONTHLY = "monthly"
+ QUARTERLY = "quarterly"
+ YEARLY = "yearly"
+
+
+SUBSCRIPTION_GRANT_COUNT = {
+ SubscriptionBillingCycle.MONTHLY.value: 1,
+ SubscriptionBillingCycle.QUARTERLY.value: 3,
+ SubscriptionBillingCycle.YEARLY.value: 12,
+}
+
+
+class SubscriptionTierCode(StrEnum):
+ STARTER = "starter"
+ STANDARD = "standard"
+ ADVANCED = "advanced"
+ SUPER = "super"
+
+
+class ProductPriceType(StrEnum):
+ FIRST_PURCHASE = "first_purchase"
+ REGULAR = "regular"
+ ACTIVITY = "activity"
+ UPGRADE = "upgrade"
diff --git a/video-gen-api/app/enums/credit_record.py b/video-gen-api/app/enums/credit_record.py
index 389c9674..bd74076e 100644
--- a/video-gen-api/app/enums/credit_record.py
+++ b/video-gen-api/app/enums/credit_record.py
@@ -5,7 +5,9 @@ class CreditRecordType(str, Enum):
RECHARGE = "recharge"
CONSUME = "consume"
REFUND = "refund"
- TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
+ TEAM_INTERNAL = "team_internal" # 历史团队内部积分流转
+ EXPIRE = "expire"
+ REVOKE = "revoke"
class CreditRecordOwnerType(str, Enum):
@@ -56,6 +58,8 @@ class CreditRecordMediaType(str, Enum):
class CreditRecordAction(str, Enum):
CHARGE = "charge"
REFUND = "refund"
+ PRE_DEDUCT = "pre_deduct"
+ # 历史动作,仅用于展示旧流水,新代码不得继续创建。
HOLD = "hold"
HOLD_RELEASE = "hold_release"
@@ -111,12 +115,19 @@ class CreditRecordBillingScene(str, Enum):
ADMIN_ADJUST = "admin_adjust"
REFUND = "refund"
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
+ BUSINESS_FAILURE_REFUND_AVAILABLE = "business_failure_refund_available"
+ BUSINESS_FAILURE_REFUND_EXPIRED = "business_failure_refund_expired"
+ CREDIT_EXPIRE = "credit_expire"
+ CREDIT_REVOKE = "credit_revoke"
+ SUBSCRIPTION_GRANT = "subscription_grant"
+ CREDIT_ADDON_PURCHASE = "credit_addon_purchase"
UNKNOWN = "unknown"
CREDIT_RECORD_ACTION_LABELS = {
CreditRecordAction.CHARGE.value: "真实扣费",
+ CreditRecordAction.PRE_DEDUCT.value: "固定预扣",
CreditRecordAction.REFUND.value: "真实退款",
CreditRecordAction.HOLD.value: "预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
@@ -127,6 +138,8 @@ CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.CONSUME.value: "消费",
CreditRecordType.REFUND.value: "回退",
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
+ CreditRecordType.EXPIRE.value: "过期",
+ CreditRecordType.REVOKE.value: "撤销",
}
CREDIT_RECORD_SUBJECT_LABELS = {
@@ -209,5 +222,11 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
CreditRecordBillingScene.REFUND.value: "回退",
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
+ CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_AVAILABLE.value: "业务失败有效积分退款",
+ CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_EXPIRED.value: "业务失败过期积分退款",
+ CreditRecordBillingScene.CREDIT_EXPIRE.value: "积分过期",
+ CreditRecordBillingScene.CREDIT_REVOKE.value: "积分撤销",
+ CreditRecordBillingScene.SUBSCRIPTION_GRANT.value: "订阅积分发放",
+ CreditRecordBillingScene.CREDIT_ADDON_PURCHASE.value: "积分增值包购买",
CreditRecordBillingScene.UNKNOWN.value: "历史未知",
}
diff --git a/video-gen-api/app/enums/credit_subscription.py b/video-gen-api/app/enums/credit_subscription.py
new file mode 100644
index 00000000..75f2248d
--- /dev/null
+++ b/video-gen-api/app/enums/credit_subscription.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from enum import StrEnum
+
+
+class CreditSubscriptionStatus(StrEnum):
+ PENDING = "pending"
+ ACTIVE = "active"
+ EXPIRED = "expired"
+ UPGRADED = "upgraded"
+ CANCELLED = "cancelled"
+ REFUNDED = "refunded"
+ UPGRADE_RECONCILE_FAILED = "upgrade_reconcile_failed"
+
+
+class CreditSubscriptionPeriodStatus(StrEnum):
+ SCHEDULED = "scheduled"
+ UPGRADE_RESERVED = "upgrade_reserved"
+ GRANTED = "granted"
+ CANCELLED_BY_UPGRADE = "cancelled_by_upgrade"
+ REVOKED_BY_UPGRADE = "revoked_by_upgrade"
+ CANCELLED = "cancelled"
+ EXPIRED = "expired"
diff --git a/video-gen-api/app/enums/llm_billing.py b/video-gen-api/app/enums/llm_billing.py
index 4b1f0626..a4b06ad9 100644
--- a/video-gen-api/app/enums/llm_billing.py
+++ b/video-gen-api/app/enums/llm_billing.py
@@ -3,49 +3,54 @@ from __future__ import annotations
from enum import StrEnum
-class LlmBillingConfigKey(StrEnum):
- ENABLED = "llm_billing_enabled"
- HOLD_DEFAULT = "llm_hold_credits_default"
- HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt"
- HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt"
- HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt"
- HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis"
- LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits"
-
-
class LlmBillingLedgerState(StrEnum):
- BILLING_BYPASSED = "billing_bypassed"
MISSING = "missing"
ACTIVE = "active"
- RELEASED = "released"
- CHARGED = "charged"
+ SUCCEEDED = "succeeded"
+ REFUNDED = "refunded"
+ FINAL_FAILED = "final_failed"
INVALID = "invalid"
+ BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费
+ RELEASED = "refunded" # 历史兼容别名
+ CHARGED = "succeeded" # 历史兼容别名
+
+
+
+class LlmBillingExecutionStatus(StrEnum):
+ PRE_DEDUCTED = "pre_deducted"
+ PROCESSING = "processing"
+ SUCCEEDED = "succeeded"
+ FINAL_FAILED = "final_failed"
+ REFUNDED = "refunded"
+ REFUND_FAILED = "refund_failed"
+
+
+class LlmCallAttemptStatus(StrEnum):
+ STARTED = "started"
+ SUCCEEDED = "succeeded"
+ FAILED = "failed"
+ TIMEOUT = "timeout"
+ UNKNOWN = "unknown"
class LlmBillingEvent(StrEnum):
- HOLD_START = "LLM_HOLD_START"
- HOLD_SUCCESS = "LLM_HOLD_SUCCESS"
- HOLD_BYPASSED = "LLM_HOLD_BYPASSED"
- HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT"
- HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID"
- HOLD_MISSING = "LLM_HOLD_MISSING"
- HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START"
- HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS"
- HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED"
- FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START"
- FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS"
- FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED"
+ PRE_DEDUCT_START = "LLM_PRE_DEDUCT_START"
+ PRE_DEDUCT_SUCCESS = "LLM_PRE_DEDUCT_SUCCESS"
+ PRE_DEDUCT_INSUFFICIENT = "LLM_PRE_DEDUCT_INSUFFICIENT"
+ PRE_DEDUCT_CONFIG_INVALID = "LLM_PRE_DEDUCT_CONFIG_INVALID"
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
PROVIDER_START = "LLM_PROVIDER_START"
PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS"
PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE"
- SETTLE_START = "LLM_SETTLE_START"
- SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS"
- CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
- CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE"
- SETTLE_FAILED = "LLM_SETTLE_FAILED"
+ POSTPROCESS_FAILURE = "LLM_POSTPROCESS_FAILURE"
+ TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
+ TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
+ BUSINESS_SUCCESS = "LLM_BUSINESS_SUCCESS"
+ FINAL_FAILURE_START = "LLM_FINAL_FAILURE_START"
+ FINAL_FAILURE_REFUND_SUCCESS = "LLM_FINAL_FAILURE_REFUND_SUCCESS"
+ FINAL_FAILURE_REFUND_FAILED = "LLM_FINAL_FAILURE_REFUND_FAILED"
CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START"
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
@@ -53,10 +58,18 @@ class LlmBillingEvent(StrEnum):
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
- USAGE_INVALID = "LLM_USAGE_INVALID"
- TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
- TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
class LlmBillingDomain(StrEnum):
LLM_BILLING = "llm_billing"
+
+
+LLM_BILLING_SCENE_LABELS = {
+ "generation_record_text_prompt_optimize": "AI创作-提示词优化",
+ "hot_opening_image_prompt_optimize": "爆款开头复刻-图片提示词优化",
+ "hot_opening_video_prompt_optimize": "爆款开头复刻-视频提示词优化",
+ "shot_image_prompt_optimize": "拆镜复刻-图片提示词优化",
+ "shot_video_prompt_optimize": "拆镜复刻-视频提示词优化",
+ "shot_original_video_analysis": "拆镜复刻-原视频AI分析",
+ "shot_segment_video_analysis": "拆镜复刻-片段视频AI分析",
+}
diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py
index 143ee16d..1edf76f2 100644
--- a/video-gen-api/app/main.py
+++ b/video-gen-api/app/main.py
@@ -138,7 +138,6 @@ async def _seed_data():
email="admin@videogen.ai",
phone="13800000000",
hashed_password=hash_password("123456"),
- credits=10000,
is_admin=True,
user_type="admin",
)
@@ -154,7 +153,6 @@ async def _seed_data():
email="demo@videogen.ai",
phone="13888888888",
hashed_password=hash_password("123456"),
- credits=2680,
is_admin=False,
user_type="frontend",
)
@@ -177,21 +175,12 @@ async def _seed_data():
("payment_alipay_enabled", "false", "支付宝启用"),
("payment_alipay_app_id", "", "支付宝AppID"),
("payment_alipay_private_key", "", "支付宝私钥"),
- # Text credit config
- ("text_credits_per_1000_tokens", "1", "每1000 token消耗文本积分"),
# User credits config
("user_register_credits", "100", "用户注册赠送积分"),
("user_login_credits", "0", "用户每日登录赠送积分"),
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
# Operation manual
("operation_manual", "", "操作手册链接"),
- ("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
- ("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"),
- ("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"),
- ("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"),
- ("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"),
- ("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"),
- ("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"),
]
for key, value, desc in configs:
existing = await db.execute(
@@ -439,7 +428,9 @@ async def _seed_data():
("/credit-records", "交易流水", "WalletOutlined", 2, None),
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
- ("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
+ ("/credit-products", "积分产品", "GiftOutlined", 4, None),
+ ("/llm-billing-policies", "LLM预扣配置", "RobotOutlined", 5, "模型设置"),
+ ("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"),
("/notifications", "消息推送", "BellOutlined", 5, None),
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
@@ -471,34 +462,7 @@ async def _seed_data():
logging.info("Default menu configs inserted successfully")
- # Seed recharge packages
- from app.models.recharge_package import RechargePackage
-
- default_packages = [
- ("体验包", 500, 49, 0, "首次体验推荐", "normal", 0),
- ("进阶包", 2000, 168, 200, "最受欢迎", "normal", 1),
- ("专业包", 5000, 388, 500, "高性价比", "normal", 2),
- ("企业包", 20000, 1280, 2000, "团队首选", "normal", 3),
- ]
- for name, credits, price, bonus, desc, ptype, order in default_packages:
- existing = await db.execute(
- select(RechargePackage).where(RechargePackage.name == name).limit(1)
- )
- if not existing.scalar_one_or_none():
- db.add(
- RechargePackage(
- id=generate_id(),
- name=name,
- credits=credits,
- price=price,
- bonus_credits=bonus,
- description=desc,
- package_type=ptype,
- is_gift=False,
- is_active=True,
- sort_order=order,
- )
- )
+ # 积分订阅套餐和增值包只允许管理后台人工录入;未配置时客户端不展示购买入口。
# Seed industry configs
from app.models.industry_config import IndustryConfig
diff --git a/video-gen-api/app/models/__init__.py b/video-gen-api/app/models/__init__.py
index 25a6481a..1f48bea5 100644
--- a/video-gen-api/app/models/__init__.py
+++ b/video-gen-api/app/models/__init__.py
@@ -17,6 +17,11 @@ from app.models.video_engine import VideoEngine
from app.models.credit_ratio import CreditRatio
from app.models.menu_config import MenuConfig
from app.models.recharge_package import RechargePackage
+from app.models.credit import (
+ CreditProduct, CreditRecordAllocation, UserCreditBalance,
+ UserCreditSubscription, UserCreditSubscriptionPeriod,
+)
+from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
@@ -44,7 +49,10 @@ __all__ = [
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
- "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
+ "MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
+ "UserCreditSubscription", "UserCreditSubscriptionPeriod",
+ "LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
+ "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
diff --git a/video-gen-api/app/models/credit/__init__.py b/video-gen-api/app/models/credit/__init__.py
new file mode 100644
index 00000000..b0a32566
--- /dev/null
+++ b/video-gen-api/app/models/credit/__init__.py
@@ -0,0 +1,13 @@
+from app.models.credit.allocation import CreditRecordAllocation
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit.product import CreditProduct
+from app.models.credit.subscription import UserCreditSubscription
+from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
+
+__all__ = [
+ "CreditRecordAllocation",
+ "UserCreditBalance",
+ "CreditProduct",
+ "UserCreditSubscription",
+ "UserCreditSubscriptionPeriod",
+]
diff --git a/video-gen-api/app/models/credit/allocation.py b/video-gen-api/app/models/credit/allocation.py
new file mode 100644
index 00000000..ab2c72bb
--- /dev/null
+++ b/video-gen-api/app/models/credit/allocation.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base, TimestampMixin
+
+
+class CreditRecordAllocation(Base, TimestampMixin):
+ __tablename__ = "credit_record_allocations"
+ __table_args__ = (
+ Index("ix_credit_record_allocations_record", "credit_record_id", "id"),
+ Index("ix_credit_record_allocations_balance", "credit_balance_id", "created_at"),
+ Index("ix_credit_record_allocations_user_time", "user_id", "created_at"),
+ Index("ix_credit_record_allocations_source_allocation", "source_allocation_id"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ credit_record_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("credit_records.id", ondelete="CASCADE"), nullable=False
+ )
+ credit_balance_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
+ )
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+ )
+ source_allocation_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
+ )
+ allocation_action: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
+ amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+ credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
+ source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
+ source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+
+ unspent_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ unspent_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ consumed_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ consumed_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
diff --git a/video-gen-api/app/models/credit/balance.py b/video-gen-api/app/models/credit/balance.py
new file mode 100644
index 00000000..e4512b0c
--- /dev/null
+++ b/video-gen-api/app/models/credit/balance.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.credit_balance import CreditBalanceStatus
+from app.models.base import Base, TimestampMixin
+
+
+class UserCreditBalance(Base, TimestampMixin):
+ __tablename__ = "user_credit_balances"
+ __table_args__ = (
+ CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
+ CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
+ CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
+ CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
+ CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
+ CheckConstraint(
+ "grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
+ name="ck_user_credit_balances_amount_reconciled",
+ ),
+ CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
+ Index(
+ "ix_user_credit_balances_spendable",
+ "user_id",
+ "credit_level_rank",
+ "expires_at",
+ "valid_from",
+ "id",
+ postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
+ ),
+ Index(
+ "ix_user_credit_balances_expire_due",
+ "expires_at",
+ "id",
+ postgresql_where=text(
+ "unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
+ ),
+ ),
+ Index("ix_user_credit_balances_source", "source_type", "source_id"),
+ Index("ix_user_credit_balances_payment", "payment_order_id"),
+ Index("ix_user_credit_balances_subscription", "subscription_id", "subscription_period_id"),
+ Index("uq_user_credit_balances_user_biz_key", "user_id", "biz_key", unique=True),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ credit_level: Mapped[str] = mapped_column(String(32), nullable=False)
+ credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
+
+ source_type: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
+ source_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ product_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
+ )
+ payment_order_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
+ )
+ subscription_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
+ )
+ subscription_period_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="SET NULL"), nullable=True
+ )
+ grant_record_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("credit_records.id", ondelete="SET NULL"), nullable=True, index=True
+ )
+
+ grant_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ unspent_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ consumed_amount: Mapped[Decimal] = mapped_column(
+ Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
+ )
+ expired_amount: Mapped[Decimal] = mapped_column(
+ Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
+ )
+ revoked_amount: Mapped[Decimal] = mapped_column(
+ Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
+ )
+
+ valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
+ status: Mapped[str] = mapped_column(
+ String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value, server_default=CreditBalanceStatus.ACTIVE.value
+ )
+ expired_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ biz_key: Mapped[str] = mapped_column(String(180), nullable=False)
+ metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
diff --git a/video-gen-api/app/models/credit/product.py b/video-gen-api/app/models/credit/product.py
new file mode 100644
index 00000000..2027d7cf
--- /dev/null
+++ b/video-gen-api/app/models/credit/product.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import Boolean, CheckConstraint, DateTime, Index, JSON, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.credit_balance import CreditLevel
+from app.enums.credit_product import CreditProductType
+from app.models.base import Base, TimestampMixin
+
+
+class CreditProduct(Base, TimestampMixin):
+ __tablename__ = "credit_products"
+ __table_args__ = (
+ Index("uq_credit_products_code", "product_code", unique=True),
+ Index("ix_credit_products_public", "product_type", "is_active", "sort_order"),
+ CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
+ CheckConstraint("first_purchase_price IS NULL OR first_purchase_price >= 0", name="ck_credit_products_first_price_nonnegative"),
+ CheckConstraint("regular_price IS NULL OR regular_price >= 0", name="ck_credit_products_regular_price_nonnegative"),
+ CheckConstraint("activity_price IS NULL OR activity_price >= 0", name="ck_credit_products_activity_price_nonnegative"),
+ CheckConstraint("monthly_grant_credits IS NULL OR monthly_grant_credits > 0", name="ck_credit_products_monthly_grant_positive"),
+ CheckConstraint("grant_credits IS NULL OR grant_credits > 0", name="ck_credit_products_grant_positive"),
+ CheckConstraint(
+ "(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
+ "AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
+ "AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
+ "AND grant_credits IS NULL AND validity_months IS NULL) "
+ "OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
+ "AND validity_months = 1 AND tier_code IS NULL AND tier_rank IS NULL "
+ "AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
+ "AND first_purchase_price IS NULL AND regular_price IS NULL "
+ "AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
+ name="ck_credit_products_type_required_fields",
+ ),
+ CheckConstraint(
+ "(activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL) "
+ "OR (activity_price IS NOT NULL AND activity_start_at IS NOT NULL "
+ "AND activity_end_at IS NOT NULL AND activity_end_at > activity_start_at)",
+ name="ck_credit_products_activity_window",
+ ),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ product_code: Mapped[str] = mapped_column(String(64), nullable=False)
+ product_type: Mapped[str] = mapped_column(String(24), nullable=False, index=True)
+ name: Mapped[str] = mapped_column(String(96), nullable=False)
+ description: Mapped[str | None] = mapped_column(String(512), nullable=True)
+ features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
+
+ # 订阅套餐字段
+ tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ tier_rank: Mapped[int | None] = mapped_column(nullable=True)
+ billing_cycle: Mapped[str | None] = mapped_column(String(24), nullable=True)
+ monthly_grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ first_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ regular_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ activity_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ activity_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ renewal_enabled: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=True, server_default="true"
+ )
+
+ # 积分增值包字段;当前固定一个自然月有效。
+ grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ validity_months: Mapped[int | None] = mapped_column(nullable=True)
+
+ price: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
+ credit_level: Mapped[str] = mapped_column(
+ String(32), nullable=False, default=CreditLevel.GENERAL.value, server_default=CreditLevel.GENERAL.value
+ )
+ currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY", server_default="CNY")
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
+ sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+
+ @property
+ def is_subscription(self) -> bool:
+ return self.product_type == CreditProductType.SUBSCRIPTION.value
+
+ @property
+ def is_credit_addon(self) -> bool:
+ return self.product_type == CreditProductType.CREDIT_ADDON.value
diff --git a/video-gen-api/app/models/credit/subscription.py b/video-gen-api/app/models/credit/subscription.py
new file mode 100644
index 00000000..96c39d1c
--- /dev/null
+++ b/video-gen-api/app/models/credit/subscription.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.credit_subscription import CreditSubscriptionStatus
+from app.models.base import Base, TimestampMixin
+
+
+class UserCreditSubscription(Base, TimestampMixin):
+ __tablename__ = "user_credit_subscriptions"
+ __table_args__ = (
+ Index("ix_user_credit_subscriptions_current", "user_id", "status", "expires_at"),
+ Index("ix_user_credit_subscriptions_expire_due", "status", "expires_at", "id"),
+ Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_at", "id"),
+ Index("uq_user_credit_subscriptions_payment", "payment_order_id", unique=True),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ product_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
+ )
+ payment_order_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("payment_orders.id", ondelete="RESTRICT"), nullable=False
+ )
+ status: Mapped[str] = mapped_column(
+ String(32), nullable=False, default=CreditSubscriptionStatus.PENDING.value,
+ server_default=CreditSubscriptionStatus.PENDING.value,
+ )
+ purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
+ tier_code: Mapped[str] = mapped_column(String(32), nullable=False)
+ tier_rank: Mapped[int] = mapped_column(nullable=False)
+ billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
+ anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ monthly_grant_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ grant_count: Mapped[int] = mapped_column(nullable=False)
+ granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
+ source_subscription_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
+ )
+ upgrade_order_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
+ )
diff --git a/video-gen-api/app/models/credit/subscription_period.py b/video-gen-api/app/models/credit/subscription_period.py
new file mode 100644
index 00000000..01233d0a
--- /dev/null
+++ b/video-gen-api/app/models/credit/subscription_period.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
+from app.models.base import Base, TimestampMixin
+
+
+class UserCreditSubscriptionPeriod(Base, TimestampMixin):
+ __tablename__ = "user_credit_subscription_periods"
+ __table_args__ = (
+ Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
+ Index("ix_user_credit_subscription_periods_due", "status", "scheduled_at", "id"),
+ Index("ix_user_credit_subscription_periods_upgrade", "upgrade_order_id", "status"),
+ Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ subscription_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("user_credit_subscriptions.id", ondelete="CASCADE"), nullable=False
+ )
+ sequence: Mapped[int] = mapped_column(nullable=False)
+ scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ grant_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ allocated_paid_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ status: Mapped[str] = mapped_column(
+ String(32), nullable=False, default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ server_default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ )
+ issued_balance_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
+ )
+ issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ upgrade_order_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
+ )
+ reserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
diff --git a/video-gen-api/app/models/credit_record.py b/video-gen-api/app/models/credit_record.py
index 3f0928b3..a7cba4d1 100644
--- a/video-gen-api/app/models/credit_record.py
+++ b/video-gen-api/app/models/credit_record.py
@@ -1,4 +1,9 @@
-from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -7,14 +12,11 @@ from app.models.base import Base, TimestampMixin
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(
- "uq_credit_records_user_refund_target",
- "user_id",
- "refund_for_biz_key",
+ "uq_credit_records_user_refund_target_kind",
+ "user_id", "refund_for_biz_key", "refund_kind",
unique=True,
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
),
@@ -31,30 +33,30 @@ class CreditRecord(Base, TimestampMixin):
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)
- description: Mapped[str] = mapped_column(String(256))
+ type: Mapped[str] = mapped_column(String(24), index=True)
+ amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ balance_delta: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
+ expired_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
+ balance_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ description: Mapped[str] = mapped_column(String(512))
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ request_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
- # 当前积分流水自己的业务幂等键。
- # 例如:generation_record:{record_id}:attempt:1:media:charge
- biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
+ biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
+ refund_for_biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
+ refund_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
- # 如果当前流水是退款,记录它退的是哪一次扣费。
- # 例如:generation_record:{record_id}:attempt:1:media:charge
- refund_for_biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
-
- # 账务快照字段:保证业务步骤/资源软删后,流水仍可独立展示。
owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
charge_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
- charge_action: Mapped[str | None] = mapped_column(String(16), nullable=True)
+ charge_action: Mapped[str | None] = mapped_column(String(24), nullable=True)
credit_subject: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
media_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
billing_scene: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
+ scene_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ credit_level_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_project_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -65,7 +67,10 @@ class CreditRecord(Base, TimestampMixin):
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
-
+ llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ llm_success_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ llm_failed_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ llm_billing_execution_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=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)
@@ -75,6 +80,5 @@ class CreditRecord(Base, TimestampMixin):
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
- # 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
diff --git a/video-gen-api/app/models/llm_billing/__init__.py b/video-gen-api/app/models/llm_billing/__init__.py
new file mode 100644
index 00000000..15f0fb05
--- /dev/null
+++ b/video-gen-api/app/models/llm_billing/__init__.py
@@ -0,0 +1,5 @@
+from app.models.llm_billing.call_attempt import LlmCallAttempt
+from app.models.llm_billing.execution import LlmBillingExecution
+from app.models.llm_billing.policy import LlmBillingPolicyModel
+
+__all__ = ["LlmCallAttempt", "LlmBillingExecution", "LlmBillingPolicyModel"]
diff --git a/video-gen-api/app/models/llm_billing/call_attempt.py b/video-gen-api/app/models/llm_billing/call_attempt.py
new file mode 100644
index 00000000..16b49182
--- /dev/null
+++ b/video-gen-api/app/models/llm_billing/call_attempt.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.llm_billing import LlmCallAttemptStatus
+from app.models.base import Base, TimestampMixin
+
+
+class LlmCallAttempt(Base, TimestampMixin):
+ __tablename__ = "llm_call_attempts"
+ __table_args__ = (
+ Index("uq_llm_call_attempts_sequence", "billing_execution_id", "call_sequence", unique=True),
+ Index("ix_llm_call_attempts_execution_time", "billing_execution_id", "created_at"),
+ Index("ix_llm_call_attempts_provider_request", "provider_request_id"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ billing_execution_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("llm_billing_executions.id", ondelete="CASCADE"), nullable=False
+ )
+ call_sequence: Mapped[int] = mapped_column(nullable=False)
+ retry_sequence: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ model_config_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
+ )
+ model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ provider_request_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
+ request_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ response_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ status: Mapped[str] = mapped_column(
+ String(24), nullable=False, default=LlmCallAttemptStatus.STARTED.value,
+ server_default=LlmCallAttemptStatus.STARTED.value,
+ )
+ input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ token_usage_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("token_usage.id", ondelete="SET NULL"), nullable=True
+ )
+ http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ provider_error_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
+ token_unavailable_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
+ postprocess_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ postprocess_error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
diff --git a/video-gen-api/app/models/llm_billing/execution.py b/video-gen-api/app/models/llm_billing/execution.py
new file mode 100644
index 00000000..32ba541b
--- /dev/null
+++ b/video-gen-api/app/models/llm_billing/execution.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.enums.llm_billing import LlmBillingExecutionStatus
+from app.models.base import Base, TimestampMixin
+
+
+class LlmBillingExecution(Base, TimestampMixin):
+ __tablename__ = "llm_billing_executions"
+ __table_args__ = (
+ Index(
+ "uq_llm_billing_executions_business_attempt",
+ "user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no",
+ unique=True,
+ ),
+ Index("uq_llm_billing_executions_credit_record", "credit_record_id", unique=True),
+ Index("ix_llm_billing_executions_status_time", "status", "created_at"),
+ )
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ user_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ scene_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ scene_name_snapshot: Mapped[str] = mapped_column(String(128), nullable=False)
+ owner_type: Mapped[str] = mapped_column(String(64), nullable=False)
+ owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
+ business_attempt_no: Mapped[int] = mapped_column(nullable=False)
+
+ model_config_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
+ )
+ model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ model_parameters_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+ billing_policy_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("llm_billing_policies.id", ondelete="SET NULL"), nullable=True
+ )
+ billing_policy_version: Mapped[int | None] = mapped_column(nullable=True)
+ request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ credit_record_id: Mapped[str] = mapped_column(
+ String(32), ForeignKey("credit_records.id", ondelete="RESTRICT"), nullable=False
+ )
+ status: Mapped[str] = mapped_column(
+ String(32), nullable=False, default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
+ server_default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
+ )
+
+ total_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ successful_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ failed_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ total_input_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ total_output_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+ total_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
+
+ refund_available_credits: Mapped[Decimal] = mapped_column(
+ Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
+ )
+ refund_expired_credits: Mapped[Decimal] = mapped_column(
+ Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
+ )
+ final_error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
+ completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
diff --git a/video-gen-api/app/models/llm_billing/policy.py b/video-gen-api/app/models/llm_billing/policy.py
new file mode 100644
index 00000000..c627bab2
--- /dev/null
+++ b/video-gen-api/app/models/llm_billing/policy.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from decimal import Decimal
+
+from sqlalchemy import Boolean, Index, Numeric, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.base import Base, TimestampMixin
+
+
+class LlmBillingPolicyModel(Base, TimestampMixin):
+ __tablename__ = "llm_billing_policies"
+ __table_args__ = (Index("uq_llm_billing_policies_scene", "scene_code", unique=True),)
+
+ id: Mapped[str] = mapped_column(String(32), primary_key=True)
+ scene_code: Mapped[str] = mapped_column(String(64), nullable=False)
+ scene_name: Mapped[str] = mapped_column(String(128), nullable=False)
+ pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
+ version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
+ created_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+ updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
diff --git a/video-gen-api/app/models/payment_order.py b/video-gen-api/app/models/payment_order.py
index c9da1ea4..059ffaf3 100644
--- a/video-gen-api/app/models/payment_order.py
+++ b/video-gen-api/app/models/payment_order.py
@@ -1,6 +1,9 @@
-from datetime import datetime
+from __future__ import annotations
-from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -8,27 +11,40 @@ from app.models.base import Base, TimestampMixin
class PaymentOrder(Base, TimestampMixin):
__tablename__ = "payment_orders"
+ __table_args__ = (
+ Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
+ Index("idx_payorder_status_created", "status", "created_at"),
+ )
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
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
- amount: Mapped[float] = mapped_column(Float)
- credits: Mapped[float] = mapped_column(Float)
+ amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
+ credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
payment_method: Mapped[str] = mapped_column(String(16))
- status: Mapped[str] = mapped_column(String(16), default="pending")
- paid_at: Mapped[datetime | None] = mapped_column(
- DateTime(timezone=True), nullable=True
- )
+ status: Mapped[str] = mapped_column(String(32), default="pending")
+ paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
- refunded_at: Mapped[datetime | None] = mapped_column(
- DateTime(timezone=True), nullable=True
- )
- refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
+ refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ refund_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
- __table_args__ = (
- Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
- Index('idx_payorder_status_created', 'status', 'created_at'),
+ product_id: Mapped[str | None] = mapped_column(
+ String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
)
+ product_type: Mapped[str | None] = mapped_column(String(24), nullable=True, index=True)
+ purchase_scene: Mapped[str | None] = mapped_column(String(24), nullable=True)
+ price_type: Mapped[str | None] = mapped_column(String(24), nullable=True)
+ product_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ product_name_snapshot: Mapped[str | None] = mapped_column(String(96), nullable=True)
+ product_snapshot_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+ subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
+ source_subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
+ upgrade_period_ids_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
+ target_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ deduction_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ payable_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
+ fulfillment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
+ fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
diff --git a/video-gen-api/app/models/recharge_package.py b/video-gen-api/app/models/recharge_package.py
index 4e7e21f0..237c7b49 100644
--- a/video-gen-api/app/models/recharge_package.py
+++ b/video-gen-api/app/models/recharge_package.py
@@ -1,19 +1,9 @@
-from sqlalchemy import Boolean, Integer, String, Float
-from sqlalchemy.orm import Mapped, mapped_column
+"""兼容旧导入路径。
-from app.models.base import Base, TimestampMixin
+充值产品已统一迁移为 credit_products 表;新代码应直接导入 CreditProduct。
+"""
+from app.models.credit.product import CreditProduct
+RechargePackage = CreditProduct
-class RechargePackage(Base, TimestampMixin):
- __tablename__ = "recharge_packages"
-
- id: Mapped[str] = mapped_column(String(32), primary_key=True)
- name: Mapped[str] = mapped_column(String(64))
- credits: Mapped[float] = mapped_column(Float)
- price: Mapped[float] = mapped_column(Float)
- bonus_credits: Mapped[float] = mapped_column(Float, default=0.0)
- description: Mapped[str | None] = mapped_column(String(256), nullable=True)
- package_type: Mapped[str] = mapped_column(String(32), default="normal")
- is_gift: Mapped[bool] = mapped_column(Boolean, default=False)
- is_active: Mapped[bool] = mapped_column(Boolean, default=True)
- sort_order: Mapped[int] = mapped_column(Integer, default=0)
+__all__ = ["CreditProduct", "RechargePackage"]
diff --git a/video-gen-api/app/models/user.py b/video-gen-api/app/models/user.py
index 57801686..0681ada4 100644
--- a/video-gen-api/app/models/user.py
+++ b/video-gen-api/app/models/user.py
@@ -1,6 +1,6 @@
from datetime import datetime
-from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
+from sqlalchemy import Boolean, DateTime, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind
@@ -9,19 +9,17 @@ from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
+ __allow_unmapped__ = True
id: Mapped[str] = mapped_column(String(32), primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True)
- # 短信注册用户允许先没有密码,后续通过 /auth/set-password 设置。
hashed_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True)
- credits: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
user_type: Mapped[str] = mapped_column(String(16), default="frontend", index=True)
- # 仅前台用户有业务意义;默认外部用户。取消内部标记时也设置回 external。
frontend_user_kind: Mapped[str] = mapped_column(
String(16),
default=FrontendUserKind.EXTERNAL.value,
@@ -29,21 +27,25 @@ class User(Base, TimestampMixin):
index=True,
nullable=False,
)
- # 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
- last_login_at: Mapped[datetime | None] = mapped_column(
- DateTime(timezone=True), nullable=True
- )
- password_set_at: Mapped[datetime | None] = mapped_column(
- DateTime(timezone=True), nullable=True
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ password_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ first_membership_paid_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, index=True
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
-
- # 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=50, server_default="50", nullable=False
)
+ @property
+ def credits(self) -> float:
+ return float(getattr(self, "_credits_snapshot", 0.0) or 0.0)
+
+ @credits.setter
+ def credits(self, value: float | int) -> None:
+ self._credits_snapshot = round(float(value or 0.0), 2)
+
@property
def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password
diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py
index 95e58d1a..d185ce44 100644
--- a/video-gen-api/app/schemas/admin.py
+++ b/video-gen-api/app/schemas/admin.py
@@ -176,6 +176,24 @@ class AdminCreditRecordSummaryOut(BaseModel):
output_tokens: int = 0
+
+
+class AdminCreditRecordAllocationOut(BaseModel):
+ id: str
+ credit_balance_id: str
+ source_allocation_id: str | None = None
+ allocation_action: str
+ amount: float
+ credit_level: str
+ source_type: str
+ source_id: str | None = None
+ valid_from: str | None = None
+ expires_at: str | None = None
+ unspent_before: float = 0.0
+ unspent_after: float = 0.0
+ consumed_before: float = 0.0
+ consumed_after: float = 0.0
+
class AdminCreditRecordOut(BaseModel):
id: str
user_id: str
@@ -192,6 +210,8 @@ class AdminCreditRecordOut(BaseModel):
record_type: str
record_type_label: str | None = None
amount: float
+ balance_delta: float = 0.0
+ expired_amount: float = 0.0
balance_after: float
description: str | None = None
related_id: str | None = None
@@ -212,6 +232,8 @@ class AdminCreditRecordOut(BaseModel):
media_type_label: str | None = None
billing_scene: str | None = None
billing_scene_label: str | None = None
+ scene_name_snapshot: str | None = None
+ request_time: str | None = None
source_module: str | None = None
source_module_label: str | None = None
source_project_id: str | None = None
@@ -222,6 +244,10 @@ class AdminCreditRecordOut(BaseModel):
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
+ llm_call_count: int = 0
+ llm_success_call_count: int = 0
+ llm_failed_call_count: int = 0
+ allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list)
engine_type: str | None = None
engine_id: str | None = None
engine_name: str | None = None
diff --git a/video-gen-api/app/schemas/credit.py b/video-gen-api/app/schemas/credit.py
index 040bf9a6..bdaaafaf 100644
--- a/video-gen-api/app/schemas/credit.py
+++ b/video-gen-api/app/schemas/credit.py
@@ -1,18 +1,36 @@
-from pydantic import BaseModel
+from __future__ import annotations
-from app.schemas.common import NaiveDatetime
+from datetime import datetime
+
+from pydantic import BaseModel
class CreditRecordOut(BaseModel):
id: str
type: str
amount: float
+ balance_delta: float = 0
+ expired_amount: float = 0
+ balance_after: float
description: str
- created_at: NaiveDatetime
+ billing_scene: str | None = None
+ scene_name_snapshot: str | None = None
+ input_tokens: int | None = None
+ output_tokens: int | None = None
+ total_tokens: int | None = None
+ llm_call_count: int | None = None
+ llm_success_call_count: int | None = None
+ llm_failed_call_count: int | None = None
+ created_at: datetime
model_config = {"from_attributes": True}
class CreditBalanceOut(BaseModel):
credits: float
+ available_credits: float
+ next_expiring_credits: float = 0
+ next_expires_at: datetime | None = None
+ next_last_usable_at: datetime | None = None
records: list[CreditRecordOut]
+ total: int = 0
diff --git a/video-gen-api/app/schemas/credit_balance.py b/video-gen-api/app/schemas/credit_balance.py
new file mode 100644
index 00000000..48f90aa1
--- /dev/null
+++ b/video-gen-api/app/schemas/credit_balance.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class CreditBalanceItemOut(BaseModel):
+ id: str
+ credit_level: str
+ source_type: str
+ source_id: str | None = None
+ product_id: str | None = None
+ payment_order_id: str | None = None
+ subscription_id: str | None = None
+ subscription_period_id: str | None = None
+ grant_amount: float
+ unspent_amount: float
+ consumed_amount: float
+ expired_amount: float
+ revoked_amount: float
+ valid_from: datetime
+ expires_at: datetime
+ last_usable_at: datetime
+ status: str
+ created_at: datetime
+
+
+class CreditBalanceSummaryOut(BaseModel):
+ credits: float
+ available_credits: float
+ next_expiring_credits: float
+ next_expires_at: datetime | None = None
+ next_last_usable_at: datetime | None = None
+
+
+class AdminCreditGrantRequest(BaseModel):
+ amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
+ description: str = Field(..., min_length=1, max_length=256)
+ valid_from: datetime | None = None
+ validity_unit: str = Field(default="month", pattern="^(day|month)$")
+ validity_value: int = Field(default=1, ge=1, le=120)
+ credit_level: str = Field(default="general", pattern="^(promotional|general)$")
+
+
+class AdminCreditDeductRequest(BaseModel):
+ amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
+ description: str = Field(..., min_length=1, max_length=256)
diff --git a/video-gen-api/app/schemas/credit_product.py b/video-gen-api/app/schemas/credit_product.py
new file mode 100644
index 00000000..a2a9c947
--- /dev/null
+++ b/video-gen-api/app/schemas/credit_product.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class CreditProductBase(BaseModel):
+ product_code: str = Field(..., min_length=1, max_length=64)
+ product_type: Literal["subscription", "credit_addon"]
+ name: str = Field(..., min_length=1, max_length=96)
+ description: str | None = Field(default=None, max_length=512)
+ features: list[str] = Field(default_factory=list)
+
+ tier_code: str | None = Field(default=None, max_length=32)
+ tier_rank: int | None = Field(default=None, ge=1, le=999)
+ billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
+ monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
+ first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ activity_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ activity_start_at: datetime | None = None
+ activity_end_at: datetime | None = None
+ renewal_enabled: bool = True
+
+ price: float = Field(default=0, ge=0, le=999999999.99)
+ grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
+ credit_level: Literal["promotional", "general"] = "general"
+ currency: str = Field(default="CNY", min_length=1, max_length=8)
+ is_active: bool = True
+ sort_order: int = Field(default=0, ge=-999999, le=999999)
+
+ @model_validator(mode="after")
+ def validate_product_fields(self):
+ if self.product_type == "subscription":
+ required = {
+ "tier_code": self.tier_code,
+ "tier_rank": self.tier_rank,
+ "billing_cycle": self.billing_cycle,
+ "monthly_grant_credits": self.monthly_grant_credits,
+ "first_purchase_price": self.first_purchase_price,
+ "regular_price": self.regular_price,
+ }
+ missing = [name for name, value in required.items() if value is None]
+ if missing:
+ raise ValueError(f"订阅套餐缺少字段: {', '.join(missing)}")
+ if self.activity_price is not None:
+ if not self.activity_start_at or not self.activity_end_at:
+ raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
+ if self.activity_end_at <= self.activity_start_at:
+ raise ValueError("活动结束时间必须晚于开始时间")
+ else:
+ if self.grant_credits is None:
+ raise ValueError("积分增值包必须配置积分数量")
+ if self.price < 0:
+ raise ValueError("增值包价格不能小于0")
+ return self
+
+
+class CreditProductCreate(CreditProductBase):
+ pass
+
+
+class CreditProductUpdate(BaseModel):
+ product_code: str | None = Field(default=None, min_length=1, max_length=64)
+ name: str | None = Field(default=None, min_length=1, max_length=96)
+ description: str | None = Field(default=None, max_length=512)
+ features: list[str] | None = None
+ tier_code: str | None = Field(default=None, max_length=32)
+ tier_rank: int | None = Field(default=None, ge=1, le=999)
+ billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
+ monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
+ first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ activity_price: float | None = Field(default=None, ge=0, le=999999999.99)
+ activity_start_at: datetime | None = None
+ activity_end_at: datetime | None = None
+ renewal_enabled: bool | None = None
+ price: float | None = Field(default=None, ge=0, le=999999999.99)
+ grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
+ credit_level: Literal["promotional", "general"] | None = None
+ currency: str | None = Field(default=None, min_length=1, max_length=8)
+ is_active: bool | None = None
+ sort_order: int | None = Field(default=None, ge=-999999, le=999999)
+
+
+class CreditProductRenewalUpdate(BaseModel):
+ renewal_enabled: bool
+
+
+class CreditProductOut(BaseModel):
+ id: str
+ product_code: str
+ product_type: str
+ name: str
+ description: str | None = None
+ features: list[str] = Field(default_factory=list)
+ tier_code: str | None = None
+ tier_rank: int | None = None
+ billing_cycle: str | None = None
+ monthly_grant_credits: float = 0
+ grant_count: int = 1
+ first_purchase_price: float = 0
+ regular_price: float = 0
+ activity_price: float | None = None
+ activity_start_at: datetime | None = None
+ activity_end_at: datetime | None = None
+ renewal_enabled: bool = True
+ grant_credits: float = 0
+ validity_months: int | None = None
+ price: float
+ current_price: float
+ target_price: float | None = None
+ deduction_amount: float = 0
+ price_type: str | None = None
+ credit_level: str
+ currency: str
+ is_active: bool
+ sort_order: int
+ can_purchase: bool | None = None
+ can_upgrade: bool = False
+ unavailable_reason: str | None = None
+
+
+class CreditProductCatalogOut(BaseModel):
+ subscription_products: list[CreditProductOut]
+ credit_addons: list[CreditProductOut]
+ first_purchase_available: bool
+ current_subscription: dict | None = None
diff --git a/video-gen-api/app/schemas/credit_subscription.py b/video-gen-api/app/schemas/credit_subscription.py
new file mode 100644
index 00000000..c7696ec0
--- /dev/null
+++ b/video-gen-api/app/schemas/credit_subscription.py
@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class CreditSubscriptionPeriodOut(BaseModel):
+ id: str
+ sequence: int
+ scheduled_at: datetime
+ valid_from: datetime
+ expires_at: datetime
+ grant_credits: float
+ allocated_paid_amount: float
+ status: str
+ issued_balance_id: str | None = None
+ issued_at: datetime | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class CreditSubscriptionOut(BaseModel):
+ id: str
+ product_id: str | None = None
+ payment_order_id: str
+ status: str
+ purchase_scene: str
+ tier_code: str
+ tier_rank: int
+ billing_cycle: str
+ anchor_at: datetime
+ start_at: datetime
+ expires_at: datetime
+ next_grant_at: datetime | None = None
+ monthly_grant_credits_snapshot: float
+ grant_count: int
+ granted_count: int
+ paid_amount_snapshot: float
+ source_subscription_id: str | None = None
+ periods: list[CreditSubscriptionPeriodOut] = []
+
+ model_config = {"from_attributes": True}
diff --git a/video-gen-api/app/schemas/llm_billing.py b/video-gen-api/app/schemas/llm_billing.py
new file mode 100644
index 00000000..e97c3364
--- /dev/null
+++ b/video-gen-api/app/schemas/llm_billing.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from pydantic import BaseModel, Field
+
+
+class LlmBillingPolicyCreate(BaseModel):
+ scene_code: str = Field(..., min_length=1, max_length=64)
+ scene_name: str | None = Field(default=None, min_length=1, max_length=128)
+ pre_deduct_credits: float = Field(..., ge=0, le=999999999.99, multiple_of=0.01)
+ is_active: bool = True
+
+
+class LlmBillingPolicyUpdate(BaseModel):
+ scene_name: str | None = Field(default=None, min_length=1, max_length=128)
+ pre_deduct_credits: float | None = Field(default=None, ge=0, le=999999999.99, multiple_of=0.01)
+ is_active: bool | None = None
+
+
+class LlmBillingPolicyOut(BaseModel):
+ id: str
+ scene_code: str
+ scene_name: str
+ pre_deduct_credits: float
+ is_active: bool
+ version: int
+ created_by: str | None = None
+ updated_by: str | None = None
+ created_at: datetime
+ updated_at: datetime
+
+ model_config = {"from_attributes": True}
+
+
+class LlmCallAttemptOut(BaseModel):
+ id: str
+ call_sequence: int
+ retry_sequence: int
+ model_config_id: str | None = None
+ model_name_snapshot: str | None = None
+ provider_snapshot: str | None = None
+ provider_request_id: str | None = None
+ request_started_at: datetime
+ response_received_at: datetime | None = None
+ duration_ms: int | None = None
+ status: str
+ input_tokens: int | None = None
+ output_tokens: int | None = None
+ total_tokens: int | None = None
+ http_status: int | None = None
+ provider_error_code: str | None = None
+ error_message: str | None = None
+ token_unavailable_reason: str | None = None
+ postprocess_status: str | None = None
+ postprocess_error: str | None = None
+
+ model_config = {"from_attributes": True}
+
+
+class LlmBillingExecutionOut(BaseModel):
+ id: str
+ user_id: str
+ scene_code: str
+ scene_name_snapshot: str
+ owner_type: str
+ owner_id: str
+ business_attempt_no: int
+ model_config_id: str | None = None
+ model_name_snapshot: str | None = None
+ provider_snapshot: str | None = None
+ request_time: datetime
+ pre_deduct_credits: float
+ status: str
+ total_call_count: int
+ successful_call_count: int
+ failed_call_count: int
+ total_input_tokens: int
+ total_output_tokens: int
+ total_tokens: int
+ refund_available_credits: float
+ refund_expired_credits: float
+ final_error_message: str | None = None
+ completed_at: datetime | None = None
+ refunded_at: datetime | None = None
+ calls: list[LlmCallAttemptOut] = Field(default_factory=list)
+
+ model_config = {"from_attributes": True}
diff --git a/video-gen-api/app/schemas/payment.py b/video-gen-api/app/schemas/payment.py
index 6ba5fc8c..80f81170 100644
--- a/video-gen-api/app/schemas/payment.py
+++ b/video-gen-api/app/schemas/payment.py
@@ -1,10 +1,13 @@
-from pydantic import BaseModel
+from __future__ import annotations
+
from datetime import datetime
+from pydantic import BaseModel, Field
+
class RechargeRequest(BaseModel):
- plan: str # package id
- method: str = "wechat" # "wechat" or "alipay"
+ plan: str = Field(..., description="积分商品ID;兼容旧字段名plan")
+ method: str = "wechat"
class PaymentOrderOut(BaseModel):
@@ -14,7 +17,16 @@ class PaymentOrderOut(BaseModel):
credits: float
payment_method: str
status: str
- qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
+ product_id: str | None = None
+ product_type: str | None = None
+ purchase_scene: str | None = None
+ price_type: str | None = None
+ product_name_snapshot: str | None = None
+ target_price_snapshot: float | None = None
+ deduction_amount_snapshot: float | None = None
+ payable_amount_snapshot: float | None = None
+ fulfillment_status: str | None = None
+ qr_url: str | None = None
created_at: datetime
paid_at: datetime | None = None
diff --git a/video-gen-api/app/services/admin_credit_record_service.py b/video-gen-api/app/services/admin_credit_record_service.py
index 1223b7c4..0cf88981 100644
--- a/video-gen-api/app/services/admin_credit_record_service.py
+++ b/video-gen-api/app/services/admin_credit_record_service.py
@@ -20,6 +20,7 @@ from app.enums.credit_record import (
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
+from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
@@ -165,7 +166,12 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
return deleted_map
-def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[tuple[str, str], tuple[bool, str | None]]) -> dict[str, Any]:
+def _record_to_item(
+ record: CreditRecord,
+ user: User | None,
+ deleted_map: dict[tuple[str, str], tuple[bool, str | None]],
+ allocation_map: dict[str, list[dict[str, Any]]],
+) -> dict[str, Any]:
owner_deleted = False
owner_deleted_at = None
if record.owner_type and record.owner_id:
@@ -189,6 +195,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"record_type": record.type,
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
"amount": _round2(record.amount),
+ "balance_delta": _round2(record.balance_delta),
+ "expired_amount": _round2(record.expired_amount),
"balance_after": _round2(record.balance_after),
"description": record.description,
"related_id": record.related_id,
@@ -209,6 +217,8 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"media_type_label": _label(CREDIT_RECORD_MEDIA_TYPE_LABELS, record.media_type),
"billing_scene": record.billing_scene,
"billing_scene_label": _label(CREDIT_RECORD_BILLING_SCENE_LABELS, record.billing_scene),
+ "scene_name_snapshot": record.scene_name_snapshot,
+ "request_time": _iso(record.request_time),
"source_module": record.source_module,
"source_module_label": _label(CREDIT_RECORD_SOURCE_MODULE_LABELS, record.source_module),
"source_project_id": record.source_project_id,
@@ -219,6 +229,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"input_tokens": record.input_tokens or 0,
"output_tokens": record.output_tokens or 0,
"total_tokens": record.total_tokens or 0,
+ "llm_call_count": record.llm_call_count or 0,
+ "llm_success_call_count": record.llm_success_call_count or 0,
+ "llm_failed_call_count": record.llm_failed_call_count or 0,
+ "allocations": allocation_map.get(record.id, []),
"engine_type": record.engine_type,
"engine_id": record.engine_id,
"engine_name": record.engine_name,
@@ -285,7 +299,32 @@ async def list_admin_credit_records(
rows = result.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]
+ record_ids = [record.id for record in records]
+ allocation_map: dict[str, list[dict[str, Any]]] = {record_id: [] for record_id in record_ids}
+ if record_ids:
+ allocation_result = await db.execute(
+ select(CreditRecordAllocation)
+ .where(CreditRecordAllocation.credit_record_id.in_(record_ids))
+ .order_by(CreditRecordAllocation.credit_record_id.asc(), CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
+ )
+ for allocation in allocation_result.scalars().all():
+ allocation_map.setdefault(allocation.credit_record_id, []).append({
+ "id": allocation.id,
+ "credit_balance_id": allocation.credit_balance_id,
+ "source_allocation_id": allocation.source_allocation_id,
+ "allocation_action": allocation.allocation_action,
+ "amount": _round2(allocation.amount),
+ "credit_level": allocation.credit_level_snapshot,
+ "source_type": allocation.source_type_snapshot,
+ "source_id": allocation.source_id_snapshot,
+ "valid_from": _iso(allocation.valid_from_snapshot),
+ "expires_at": _iso(allocation.expires_at_snapshot),
+ "unspent_before": _round2(allocation.unspent_before),
+ "unspent_after": _round2(allocation.unspent_after),
+ "consumed_before": _round2(allocation.consumed_before),
+ "consumed_after": _round2(allocation.consumed_after),
+ })
+ items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows]
summary_query = select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
diff --git a/video-gen-api/app/services/credit/__init__.py b/video-gen-api/app/services/credit/__init__.py
new file mode 100644
index 00000000..3d4d9f09
--- /dev/null
+++ b/video-gen-api/app/services/credit/__init__.py
@@ -0,0 +1,35 @@
+from app.services.credit.expiration_service import (
+ archive_expired_balances_batch,
+ archive_expired_user_balances,
+)
+from app.services.credit.ledger_service import (
+ CreditMutationResult,
+ CreditRefundResult,
+ deduct_credits,
+ grant_credits,
+ refund_consumption,
+ revoke_balances,
+)
+from app.services.credit.query_service import (
+ CreditBalanceSummary,
+ attach_credit_snapshot,
+ get_available_credits,
+ get_balance_summary,
+ get_user_credit_map,
+)
+
+__all__ = [
+ "CreditMutationResult",
+ "CreditRefundResult",
+ "CreditBalanceSummary",
+ "deduct_credits",
+ "grant_credits",
+ "refund_consumption",
+ "revoke_balances",
+ "archive_expired_balances_batch",
+ "archive_expired_user_balances",
+ "attach_credit_snapshot",
+ "get_available_credits",
+ "get_balance_summary",
+ "get_user_credit_map",
+]
diff --git a/video-gen-api/app/services/credit/expiration_service.py b/video-gen-api/app/services/credit/expiration_service.py
new file mode 100644
index 00000000..e85ac8d0
--- /dev/null
+++ b/video-gen-api/app/services/credit/expiration_service.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from collections import Counter
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus
+from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
+from app.models.credit.allocation import CreditRecordAllocation
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit_record import CreditRecord
+from app.services.credit.locking import acquire_user_credit_lock
+from app.services.credit.query_service import get_available_credits
+from app.services.credit.utils import to_credit_decimal, utc_now
+from app.utils.id_gen import generate_id
+
+
+async def archive_expired_user_balances(
+ db: AsyncSession,
+ *,
+ user_id: str,
+ request_time: datetime | None = None,
+ limit: int = 500,
+) -> int:
+ checked_at = request_time or utc_now()
+ await acquire_user_credit_lock(db, user_id)
+ result = await db.execute(
+ select(UserCreditBalance)
+ .where(
+ UserCreditBalance.user_id == user_id,
+ UserCreditBalance.expires_at <= checked_at,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.expired_processed_at.is_(None),
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
+ .limit(max(1, limit))
+ .with_for_update()
+ )
+ balances = list(result.scalars().all())
+ if not balances:
+ return 0
+
+ current_available = await get_available_credits(db, user_id, request_time=checked_at)
+ for balance in balances:
+ amount = to_credit_decimal(balance.unspent_amount)
+ if amount <= 0:
+ continue
+ before_consumed = to_credit_decimal(balance.consumed_amount)
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=user_id,
+ type=CreditRecordType.EXPIRE.value,
+ amount=-amount,
+ balance_delta=Decimal("0.00"),
+ expired_amount=amount,
+ balance_after=current_available,
+ description=f"积分到期:{float(amount):.2f}",
+ related_id=balance.id,
+ request_time=checked_at,
+ biz_key=f"credit-balance:{balance.id}:expire",
+ billing_scene=CreditRecordBillingScene.CREDIT_EXPIRE.value,
+ credit_level_snapshot=balance.credit_level,
+ )
+ db.add(record)
+ await db.flush()
+ db.add(
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ allocation_action=CreditAllocationAction.EXPIRE.value,
+ amount=amount,
+ request_time=checked_at,
+ credit_level_snapshot=balance.credit_level,
+ source_type_snapshot=balance.source_type,
+ source_id_snapshot=balance.source_id,
+ valid_from_snapshot=balance.valid_from,
+ expires_at_snapshot=balance.expires_at,
+ unspent_before=amount,
+ unspent_after=Decimal("0.00"),
+ consumed_before=before_consumed,
+ consumed_after=before_consumed,
+ )
+ )
+ balance.unspent_amount = Decimal("0.00")
+ balance.expired_amount = to_credit_decimal(balance.expired_amount) + amount
+ balance.expired_processed_at = checked_at
+ balance.status = CreditBalanceStatus.EXPIRED.value
+ await db.flush()
+ return len(balances)
+
+
+async def list_expired_balance_user_limits(
+ db: AsyncSession,
+ *,
+ request_time: datetime,
+ batch_size: int = 500,
+) -> list[tuple[str, int]]:
+ result = await db.execute(
+ select(UserCreditBalance.id, UserCreditBalance.user_id)
+ .where(
+ UserCreditBalance.expires_at <= request_time,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.expired_processed_at.is_(None),
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
+ .limit(max(1, batch_size))
+ )
+ selected = [(str(row.id), str(row.user_id)) for row in result.all()]
+ per_user_limit = Counter(user_id for _, user_id in selected)
+ return sorted(per_user_limit.items(), key=lambda item: item[0])
+
+
+async def archive_expired_balances_batch(
+ db: AsyncSession,
+ *,
+ request_time: datetime | None = None,
+ batch_size: int = 500,
+) -> int:
+ checked_at = request_time or utc_now()
+ user_limits = await list_expired_balance_user_limits(
+ db, request_time=checked_at, batch_size=batch_size
+ )
+ processed = 0
+ for user_id, user_limit in user_limits:
+ processed += await archive_expired_user_balances(
+ db,
+ user_id=user_id,
+ request_time=checked_at,
+ limit=user_limit,
+ )
+ return processed
diff --git a/video-gen-api/app/services/credit/ledger_service.py b/video-gen-api/app/services/credit/ledger_service.py
new file mode 100644
index 00000000..9e08bbba
--- /dev/null
+++ b/video-gen-api/app/services/credit/ledger_service.py
@@ -0,0 +1,791 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from typing import Iterable
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_balance import (
+ CREDIT_LEVEL_SORT,
+ CreditAllocationAction,
+ CreditBalanceSourceType,
+ CreditBalanceStatus,
+ CreditLevel,
+)
+from app.enums.credit_record import (
+ CreditRecordAction,
+ CreditRecordBillingScene,
+ CreditRecordType,
+)
+from app.enums.common import BillingBlockEventEnum
+from app.models.credit.allocation import CreditRecordAllocation
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit_record import CreditRecord
+from app.models.user import User
+from app.services.credit.locking import acquire_user_credit_lock
+from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
+from app.services.credit.time_policy import add_natural_months
+from app.services.credit.utils import to_credit_decimal, to_float, utc_now
+from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
+from app.services.operation_log_service import log_operation_event
+from app.utils.exceptions import InsufficientCreditsError
+from app.utils.id_gen import generate_id
+
+
+@dataclass(slots=True)
+class CreditMutationResult:
+ user: User
+ record: CreditRecord | None
+ created: bool
+ amount: float
+ balance_before: float
+ balance_after: float
+ refund_available: float = 0.0
+ refund_expired: float = 0.0
+
+
+@dataclass(slots=True, frozen=True)
+class _RefundAllocationSource:
+ original_allocation: CreditRecordAllocation
+ credit_balance_id: str
+ amount: Decimal
+
+
+@dataclass(slots=True, frozen=True)
+class CreditRefundResult:
+ records: tuple[CreditRecord, ...]
+ created: bool
+ total_amount: Decimal
+ available_amount: Decimal
+ expired_amount: Decimal
+ balance_before: Decimal
+ balance_after: Decimal
+
+
+async def _load_user(db: AsyncSession, user_id: str) -> User:
+ result = await db.execute(select(User).where(User.id == user_id).limit(1))
+ user = result.scalar_one_or_none()
+ if user is None:
+ raise ValueError("User not found")
+ return user
+
+
+async def _record_meta_kwargs(
+ db: AsyncSession,
+ user: User,
+ record_meta: CreditRecordMeta | dict | None,
+) -> dict:
+ if isinstance(record_meta, CreditRecordMeta):
+ populated = await with_user_snapshot(db, record_meta, user.id, user=user)
+ return populated.to_record_kwargs()
+ if isinstance(record_meta, dict):
+ return {key: value for key, value in record_meta.items() if value is not None}
+ return {}
+
+
+async def _find_record_by_biz_key(
+ db: AsyncSession,
+ *,
+ user_id: str,
+ biz_key: str | None,
+) -> CreditRecord | None:
+ if not biz_key:
+ return None
+ result = await db.execute(
+ select(CreditRecord)
+ .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
+ .limit(1)
+ )
+ return result.scalar_one_or_none()
+
+
+async def grant_credits(
+ db: AsyncSession,
+ *,
+ user_id: str,
+ amount: Decimal | float | int,
+ description: str,
+ source_type: str,
+ valid_from: datetime | None = None,
+ expires_at: datetime | None = None,
+ credit_level: str = CreditLevel.GENERAL.value,
+ source_id: str | None = None,
+ product_id: str | None = None,
+ payment_order_id: str | None = None,
+ subscription_id: str | None = None,
+ subscription_period_id: str | None = None,
+ related_id: str | None = None,
+ record_type: str = CreditRecordType.RECHARGE.value,
+ biz_key: str | None = None,
+ record_meta: CreditRecordMeta | dict | None = None,
+ metadata_json: dict | None = None,
+ request_time: datetime | None = None,
+) -> CreditMutationResult:
+ grant_amount = to_credit_decimal(amount)
+ checked_at = request_time or utc_now()
+ starts_at = valid_from or checked_at
+ ends_at = expires_at or add_natural_months(starts_at, 1)
+
+ await acquire_user_credit_lock(db, user_id)
+ user = await _load_user(db, user_id)
+ before = await get_available_credits(db, user_id, request_time=checked_at)
+
+ existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
+ if existing is not None:
+ attach_credit_snapshot(user, before)
+ return CreditMutationResult(
+ user=user,
+ record=existing,
+ created=False,
+ amount=abs(to_float(existing.amount)),
+ balance_before=to_float(before),
+ balance_after=to_float(before),
+ )
+ if grant_amount <= 0:
+ attach_credit_snapshot(user, before)
+ return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
+ if ends_at <= starts_at:
+ raise ValueError("积分过期时间必须晚于生效时间")
+
+ meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
+ after = before + grant_amount if starts_at <= checked_at < ends_at else before
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=user_id,
+ type=record_type,
+ amount=grant_amount,
+ balance_delta=(grant_amount if starts_at <= checked_at < ends_at else Decimal("0.00")),
+ expired_amount=Decimal("0.00"),
+ balance_after=after,
+ description=description,
+ related_id=related_id,
+ request_time=checked_at,
+ biz_key=biz_key,
+ credit_level_snapshot=credit_level,
+ **meta_kwargs,
+ )
+ db.add(record)
+ await db.flush()
+
+ balance = UserCreditBalance(
+ id=generate_id(),
+ user_id=user_id,
+ credit_level=credit_level,
+ credit_level_rank=CREDIT_LEVEL_SORT.get(credit_level, CREDIT_LEVEL_SORT[CreditLevel.GENERAL.value]),
+ source_type=source_type,
+ source_id=source_id,
+ product_id=product_id,
+ payment_order_id=payment_order_id,
+ subscription_id=subscription_id,
+ subscription_period_id=subscription_period_id,
+ grant_record_id=record.id,
+ grant_amount=grant_amount,
+ unspent_amount=grant_amount,
+ consumed_amount=Decimal("0.00"),
+ expired_amount=Decimal("0.00"),
+ revoked_amount=Decimal("0.00"),
+ valid_from=starts_at,
+ expires_at=ends_at,
+ status=CreditBalanceStatus.ACTIVE.value,
+ biz_key=f"{biz_key or record.id}:balance",
+ metadata_json=metadata_json,
+ )
+ db.add(balance)
+ await db.flush()
+
+ allocation = CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ allocation_action=CreditAllocationAction.GRANT.value,
+ amount=grant_amount,
+ request_time=checked_at,
+ credit_level_snapshot=credit_level,
+ source_type_snapshot=source_type,
+ source_id_snapshot=source_id,
+ valid_from_snapshot=starts_at,
+ expires_at_snapshot=ends_at,
+ unspent_before=Decimal("0.00"),
+ unspent_after=grant_amount,
+ consumed_before=Decimal("0.00"),
+ consumed_after=Decimal("0.00"),
+ )
+ db.add(allocation)
+ attach_credit_snapshot(user, after)
+ await db.flush()
+ log_operation_event(
+ domain="billing",
+ module="dynamic_credit",
+ event_type="CREDIT_GRANTED",
+ event_status="success",
+ source="app.services.credit.ledger_service.grant_credits",
+ user_id=user_id,
+ task_id=related_id,
+ message="动态积分发放完成",
+ detail={
+ "record_id": record.id,
+ "balance_id": balance.id,
+ "amount": to_float(grant_amount),
+ "source_type": source_type,
+ "credit_level": credit_level,
+ "valid_from": starts_at.isoformat(),
+ "expires_at": ends_at.isoformat(),
+ "biz_key": biz_key,
+ },
+ )
+ return CreditMutationResult(
+ user=user,
+ record=record,
+ created=True,
+ amount=to_float(grant_amount),
+ balance_before=to_float(before),
+ balance_after=to_float(after),
+ )
+
+
+async def deduct_credits(
+ db: AsyncSession,
+ *,
+ user_id: str,
+ amount: Decimal | float | int,
+ description: str,
+ related_id: str | None = None,
+ biz_key: str | None = None,
+ refund_for_biz_key: str | None = None,
+ record_meta: CreditRecordMeta | dict | None = None,
+ record_type: str = CreditRecordType.CONSUME.value,
+ create_zero_record: bool = False,
+ request_time: datetime | None = None,
+) -> CreditMutationResult:
+ consume_amount = to_credit_decimal(amount)
+ checked_at = request_time or utc_now()
+
+ await acquire_user_credit_lock(db, user_id)
+ user = await _load_user(db, user_id)
+ before = await get_available_credits(db, user_id, request_time=checked_at)
+
+ existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
+ if existing is not None:
+ attach_credit_snapshot(user, before)
+ return CreditMutationResult(
+ user=user,
+ record=existing,
+ created=False,
+ amount=abs(to_float(existing.amount)),
+ balance_before=to_float(before),
+ balance_after=to_float(before),
+ )
+
+ if consume_amount <= 0 and not create_zero_record:
+ attach_credit_snapshot(user, before)
+ return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
+
+ if consume_amount > before:
+ log_operation_event(
+ domain="billing",
+ module="dynamic_credit",
+ event_type=BillingBlockEventEnum.INSUFFICIENT_CREDITS.value,
+ event_status="failed",
+ source="app.services.credit.ledger_service.deduct_credits",
+ user_id=user_id,
+ task_id=related_id,
+ message="有效积分不足,已在创建业务任务前同步拦截",
+ detail={
+ "required_credits": to_float(consume_amount),
+ "available_credits": to_float(before),
+ "biz_key": biz_key,
+ "request_time": checked_at.isoformat(),
+ "description": description,
+ },
+ )
+ raise InsufficientCreditsError()
+
+ result = await db.execute(
+ select(UserCreditBalance)
+ .where(
+ UserCreditBalance.user_id == user_id,
+ UserCreditBalance.valid_from <= checked_at,
+ UserCreditBalance.expires_at > checked_at,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .order_by(
+ UserCreditBalance.credit_level_rank.asc(),
+ UserCreditBalance.expires_at.asc(),
+ UserCreditBalance.valid_from.asc(),
+ UserCreditBalance.id.asc(),
+ )
+ .with_for_update()
+ )
+ balances = list(result.scalars().all())
+
+ remaining = consume_amount
+ allocations_data: list[tuple[UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
+ for balance in balances:
+ if remaining <= 0:
+ break
+ available = to_credit_decimal(balance.unspent_amount)
+ if available <= 0:
+ continue
+ allocated = min(available, remaining)
+ before_unspent = available
+ before_consumed = to_credit_decimal(balance.consumed_amount)
+ after_unspent = before_unspent - allocated
+ after_consumed = before_consumed + allocated
+ balance.unspent_amount = after_unspent
+ balance.consumed_amount = after_consumed
+ if after_unspent == 0:
+ balance.status = CreditBalanceStatus.CONSUMED.value
+ allocations_data.append(
+ (balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed)
+ )
+ remaining -= allocated
+
+ if remaining > 0:
+ # 理论上用户级锁和前置汇总后不应发生;保留硬失败以避免部分扣除。
+ raise RuntimeError("积分分摊不足,事务将回滚")
+
+ meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
+ after = before - consume_amount
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=user_id,
+ type=record_type,
+ amount=-consume_amount,
+ balance_delta=-consume_amount,
+ expired_amount=Decimal("0.00"),
+ balance_after=after,
+ description=description,
+ related_id=related_id,
+ request_time=checked_at,
+ biz_key=biz_key,
+ refund_for_biz_key=refund_for_biz_key,
+ **meta_kwargs,
+ )
+ db.add(record)
+ await db.flush()
+
+ db.add_all(
+ [
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ allocation_action=CreditAllocationAction.CONSUME.value,
+ amount=allocated,
+ request_time=checked_at,
+ credit_level_snapshot=balance.credit_level,
+ source_type_snapshot=balance.source_type,
+ source_id_snapshot=balance.source_id,
+ valid_from_snapshot=balance.valid_from,
+ expires_at_snapshot=balance.expires_at,
+ unspent_before=before_unspent,
+ unspent_after=after_unspent,
+ consumed_before=before_consumed,
+ consumed_after=after_consumed,
+ )
+ for balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed in allocations_data
+ ]
+ )
+ attach_credit_snapshot(user, after)
+ await db.flush()
+ log_operation_event(
+ domain="billing",
+ module="dynamic_credit",
+ event_type="CREDIT_DEDUCTED",
+ event_status="success",
+ source="app.services.credit.ledger_service.deduct_credits",
+ user_id=user_id,
+ task_id=related_id,
+ message="动态积分同步扣除完成",
+ detail={
+ "record_id": record.id,
+ "amount": to_float(consume_amount),
+ "balance_before": to_float(before),
+ "balance_after": to_float(after),
+ "allocation_count": len(allocations_data),
+ "allocation_balance_ids": [item[0].id for item in allocations_data[:20]],
+ "biz_key": biz_key,
+ "request_time": checked_at.isoformat(),
+ },
+ )
+ return CreditMutationResult(
+ user=user,
+ record=record,
+ created=True,
+ amount=to_float(consume_amount),
+ balance_before=to_float(before),
+ balance_after=to_float(after),
+ )
+
+
+async def _load_original_consume_allocations(
+ db: AsyncSession,
+ *,
+ original_record_id: str,
+) -> list[_RefundAllocationSource]:
+ result = await db.execute(
+ select(CreditRecordAllocation)
+ .where(
+ CreditRecordAllocation.credit_record_id == original_record_id,
+ CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value,
+ )
+ .order_by(CreditRecordAllocation.id.desc())
+ )
+ originals = list(result.scalars().all())
+ if not originals:
+ return []
+ original_ids = [item.id for item in originals]
+ transfer_result = await db.execute(
+ select(CreditRecordAllocation)
+ .where(
+ CreditRecordAllocation.source_allocation_id.in_(original_ids),
+ CreditRecordAllocation.allocation_action
+ == CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
+ )
+ .order_by(CreditRecordAllocation.id.asc())
+ )
+ transfers_by_source: dict[str, list[CreditRecordAllocation]] = {}
+ for item in transfer_result.scalars().all():
+ if item.source_allocation_id:
+ transfers_by_source.setdefault(item.source_allocation_id, []).append(item)
+
+ sources: list[_RefundAllocationSource] = []
+ for original in originals:
+ transferred = Decimal("0.00")
+ for transfer in transfers_by_source.get(original.id, []):
+ amount = to_credit_decimal(transfer.amount)
+ transferred += amount
+ sources.append(
+ _RefundAllocationSource(
+ original_allocation=original,
+ credit_balance_id=transfer.credit_balance_id,
+ amount=amount,
+ )
+ )
+ remaining = to_credit_decimal(original.amount) - transferred
+ if remaining < 0:
+ raise RuntimeError("升级积分来源迁移金额超过原消费分摊")
+ if remaining > 0:
+ sources.append(
+ _RefundAllocationSource(
+ original_allocation=original,
+ credit_balance_id=original.credit_balance_id,
+ amount=remaining,
+ )
+ )
+ return sources
+
+
+async def refund_consumption(
+ db: AsyncSession,
+ *,
+ user_id: str,
+ refund_for_biz_key: str,
+ description: str,
+ related_id: str | None = None,
+ biz_key: str | None = None,
+ record_meta: CreditRecordMeta | dict | None = None,
+ refund_time: datetime | None = None,
+) -> CreditRefundResult:
+ checked_at = refund_time or utc_now()
+ await acquire_user_credit_lock(db, user_id)
+ user = await _load_user(db, user_id)
+ before = await get_available_credits(db, user_id, request_time=checked_at)
+
+ existing_result = await db.execute(
+ select(CreditRecord).where(
+ CreditRecord.user_id == user_id,
+ CreditRecord.refund_for_biz_key == refund_for_biz_key,
+ CreditRecord.refund_kind.in_(["available", "expired"]),
+ )
+ )
+ existing_records = list(existing_result.scalars().all())
+ if existing_records:
+ available = sum((to_credit_decimal(item.balance_delta) for item in existing_records), Decimal("0.00"))
+ expired = sum((to_credit_decimal(item.expired_amount) for item in existing_records), Decimal("0.00"))
+ attach_credit_snapshot(user, before)
+ return CreditRefundResult(
+ records=tuple(existing_records),
+ created=False,
+ total_amount=available + expired,
+ available_amount=available,
+ expired_amount=expired,
+ balance_before=before,
+ balance_after=before,
+ )
+
+ original_result = await db.execute(
+ select(CreditRecord)
+ .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == refund_for_biz_key)
+ .limit(1)
+ )
+ original = original_result.scalar_one_or_none()
+ if original is None:
+ raise ValueError("未找到原积分消费流水")
+
+ allocations = await _load_original_consume_allocations(db, original_record_id=original.id)
+ balance_ids = list(dict.fromkeys(item.credit_balance_id for item in allocations))
+ balances_result = await db.execute(
+ select(UserCreditBalance)
+ .where(UserCreditBalance.id.in_(balance_ids), UserCreditBalance.user_id == user_id)
+ .order_by(UserCreditBalance.id.asc())
+ .with_for_update()
+ )
+ balance_map = {item.id: item for item in balances_result.scalars().all()}
+
+ available_total = Decimal("0.00")
+ expired_total = Decimal("0.00")
+ available_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
+ expired_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
+
+ for allocation_source in allocations:
+ allocation = allocation_source.original_allocation
+ balance = balance_map.get(allocation_source.credit_balance_id)
+ if balance is None:
+ raise RuntimeError(f"原积分来源不存在: {allocation_source.credit_balance_id}")
+ refund_amount = to_credit_decimal(allocation_source.amount)
+ before_unspent = to_credit_decimal(balance.unspent_amount)
+ before_consumed = to_credit_decimal(balance.consumed_amount)
+ if before_consumed < refund_amount:
+ raise RuntimeError("原积分批次已消费金额不足以退款")
+ after_consumed = before_consumed - refund_amount
+ balance.consumed_amount = after_consumed
+ if checked_at < balance.expires_at and balance.revoked_at is None:
+ after_unspent = before_unspent + refund_amount
+ balance.unspent_amount = after_unspent
+ balance.status = CreditBalanceStatus.ACTIVE.value
+ available_total += refund_amount
+ available_allocations.append(
+ (allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
+ )
+ else:
+ before_expired = to_credit_decimal(balance.expired_amount)
+ balance.expired_amount = before_expired + refund_amount
+ after_unspent = before_unspent
+ if balance.unspent_amount == 0 and balance.consumed_amount == 0:
+ balance.status = CreditBalanceStatus.EXPIRED.value
+ expired_total += refund_amount
+ expired_allocations.append(
+ (allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
+ )
+
+ meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
+ created_records: list[CreditRecord] = []
+ balance_after = before + available_total
+
+ async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord:
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=user_id,
+ type=CreditRecordType.REFUND.value,
+ amount=amount,
+ balance_delta=(amount if kind == "available" else Decimal("0.00")),
+ expired_amount=expired_amount,
+ balance_after=balance_after,
+ description=(
+ f"{description},退回有效积分:{to_float(amount)}"
+ if kind == "available"
+ else f"{description},原积分已过期:{to_float(expired_amount)}"
+ ),
+ related_id=related_id,
+ request_time=checked_at,
+ biz_key=f"{biz_key or refund_for_biz_key + ':refund'}:{kind}",
+ refund_for_biz_key=refund_for_biz_key,
+ refund_kind=kind,
+ **meta_kwargs,
+ )
+ db.add(record)
+ await db.flush()
+ return record
+
+ if available_total > 0:
+ available_record = await create_refund_record("available", available_total, Decimal("0.00"))
+ created_records.append(available_record)
+ db.add_all(
+ [
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=available_record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ source_allocation_id=allocation.id,
+ allocation_action=CreditAllocationAction.REFUND_AVAILABLE.value,
+ amount=amount,
+ request_time=checked_at,
+ credit_level_snapshot=balance.credit_level,
+ source_type_snapshot=balance.source_type,
+ source_id_snapshot=balance.source_id,
+ valid_from_snapshot=balance.valid_from,
+ expires_at_snapshot=balance.expires_at,
+ unspent_before=before_unspent,
+ unspent_after=after_unspent,
+ consumed_before=before_consumed,
+ consumed_after=after_consumed,
+ )
+ for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in available_allocations
+ ]
+ )
+ if expired_total > 0:
+ expired_record = await create_refund_record("expired", expired_total, expired_total)
+ created_records.append(expired_record)
+ db.add_all(
+ [
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=expired_record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ source_allocation_id=allocation.id,
+ allocation_action=CreditAllocationAction.REFUND_EXPIRED.value,
+ amount=amount,
+ request_time=checked_at,
+ credit_level_snapshot=balance.credit_level,
+ source_type_snapshot=balance.source_type,
+ source_id_snapshot=balance.source_id,
+ valid_from_snapshot=balance.valid_from,
+ expires_at_snapshot=balance.expires_at,
+ unspent_before=before_unspent,
+ unspent_after=after_unspent,
+ consumed_before=before_consumed,
+ consumed_after=after_consumed,
+ )
+ for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in expired_allocations
+ ]
+ )
+
+ attach_credit_snapshot(user, balance_after)
+ await db.flush()
+ log_operation_event(
+ domain="billing",
+ module="dynamic_credit",
+ event_type="CREDIT_SOURCE_REFUNDED",
+ event_status="success",
+ source="app.services.credit.ledger_service.refund_consumption",
+ user_id=user_id,
+ task_id=related_id,
+ message="按原积分来源完成业务失败退款",
+ detail={
+ "refund_for_biz_key": refund_for_biz_key,
+ "available_refund": to_float(available_total),
+ "expired_refund": to_float(expired_total),
+ "source_count": len(allocations),
+ "created_record_ids": [item.id for item in created_records],
+ "refund_time": checked_at.isoformat(),
+ },
+ )
+ return CreditRefundResult(
+ records=tuple(created_records),
+ created=True,
+ total_amount=available_total + expired_total,
+ available_amount=available_total,
+ expired_amount=expired_total,
+ balance_before=before,
+ balance_after=balance_after,
+ )
+
+
+async def revoke_balances(
+ db: AsyncSession,
+ *,
+ balances: Iterable[UserCreditBalance],
+ description: str,
+ related_id: str | None,
+ biz_key: str,
+ request_time: datetime | None = None,
+) -> CreditMutationResult:
+ items = list(balances)
+ if not items:
+ raise ValueError("没有可撤销的积分")
+ checked_at = request_time or utc_now()
+ user_id = items[0].user_id
+ await acquire_user_credit_lock(db, user_id)
+ user = await _load_user(db, user_id)
+ before = await get_available_credits(db, user_id, request_time=checked_at)
+ existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
+ if existing:
+ attach_credit_snapshot(user, before)
+ return CreditMutationResult(user, existing, False, abs(to_float(existing.amount)), to_float(before), to_float(before))
+
+ total = Decimal("0.00")
+ allocation_rows: list[CreditRecordAllocation] = []
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=user_id,
+ type=CreditRecordType.REVOKE.value,
+ amount=Decimal("0.00"),
+ balance_delta=Decimal("0.00"),
+ expired_amount=Decimal("0.00"),
+ balance_after=before,
+ description=description,
+ related_id=related_id,
+ request_time=checked_at,
+ biz_key=biz_key,
+ billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
+ charge_action=CreditRecordAction.REFUND.value,
+ )
+ db.add(record)
+ await db.flush()
+
+ for balance in items:
+ if balance.user_id != user_id:
+ raise ValueError("不能跨用户撤销积分")
+ amount = to_credit_decimal(balance.unspent_amount)
+ if amount <= 0:
+ continue
+ before_unspent = amount
+ before_consumed = to_credit_decimal(balance.consumed_amount)
+ balance.unspent_amount = Decimal("0.00")
+ balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + amount
+ balance.revoked_at = checked_at
+ balance.status = CreditBalanceStatus.REVOKED.value
+ total += amount
+ allocation_rows.append(
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=balance.id,
+ user_id=user_id,
+ allocation_action=CreditAllocationAction.REVOKE.value,
+ amount=amount,
+ request_time=checked_at,
+ credit_level_snapshot=balance.credit_level,
+ source_type_snapshot=balance.source_type,
+ source_id_snapshot=balance.source_id,
+ valid_from_snapshot=balance.valid_from,
+ expires_at_snapshot=balance.expires_at,
+ unspent_before=before_unspent,
+ unspent_after=Decimal("0.00"),
+ consumed_before=before_consumed,
+ consumed_after=before_consumed,
+ )
+ )
+ after = before - total
+ record.amount = -total
+ record.balance_delta = -total
+ record.balance_after = after
+ db.add_all(allocation_rows)
+ attach_credit_snapshot(user, after)
+ await db.flush()
+ log_operation_event(
+ domain="billing",
+ module="dynamic_credit",
+ event_type="CREDIT_REVOKED",
+ event_status="success",
+ source="app.services.credit.ledger_service.revoke_balances",
+ user_id=user_id,
+ task_id=related_id,
+ message="积分批次撤销完成",
+ detail={
+ "record_id": record.id,
+ "amount": to_float(total),
+ "balance_count": len(allocation_rows),
+ "biz_key": biz_key,
+ },
+ )
+ return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after))
diff --git a/video-gen-api/app/services/credit/locking.py b/video-gen-api/app/services/credit/locking.py
new file mode 100644
index 00000000..f4bc13e0
--- /dev/null
+++ b/video-gen-api/app/services/credit/locking.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
+ """PostgreSQL 事务级用户锁;SQLite 调试环境无需额外锁。"""
+ bind = db.get_bind()
+ dialect_name = bind.dialect.name if bind is not None else ""
+ if dialect_name == "postgresql":
+ await db.execute(
+ text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"),
+ {"lock_key": f"credit:{user_id}"},
+ )
diff --git a/video-gen-api/app/services/credit/product_service.py b/video-gen-api/app/services/credit/product_service.py
new file mode 100644
index 00000000..817f95f5
--- /dev/null
+++ b/video-gen-api/app/services/credit/product_service.py
@@ -0,0 +1,271 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_product import (
+ CreditProductType,
+ ProductPriceType,
+ SUBSCRIPTION_GRANT_COUNT,
+ SubscriptionBillingCycle,
+)
+from app.enums.credit_subscription import (
+ CreditSubscriptionPeriodStatus,
+ CreditSubscriptionStatus,
+)
+from app.models.credit.product import CreditProduct
+from app.models.credit.subscription import UserCreditSubscription
+from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
+from app.models.user import User
+from app.services.credit.utils import to_credit_decimal, utc_now
+
+
+@dataclass(slots=True, frozen=True)
+class ProductPriceQuote:
+ product: CreditProduct
+ purchase_scene: str
+ price_type: str
+ base_price: Decimal
+ activity_price: Decimal | None
+ target_price: Decimal
+ deduction_amount: Decimal
+ payable_amount: Decimal
+ source_subscription_id: str | None = None
+ upgrade_period_ids: tuple[str, ...] = ()
+
+
+def grant_count_for_cycle(cycle: str | None) -> int:
+ try:
+ return SUBSCRIPTION_GRANT_COUNT[str(cycle)]
+ except KeyError as exc:
+ raise ValueError("不支持的订阅周期") from exc
+
+
+def activity_price_if_valid(product: CreditProduct, request_time: datetime) -> Decimal | None:
+ if product.activity_price is None:
+ return None
+ if product.activity_start_at is None or product.activity_end_at is None:
+ return None
+ if not (product.activity_start_at <= request_time < product.activity_end_at):
+ return None
+ return to_credit_decimal(product.activity_price)
+
+
+def current_product_price(
+ product: CreditProduct,
+ *,
+ first_purchase: bool,
+ request_time: datetime,
+ upgrade: bool = False,
+) -> tuple[Decimal, Decimal | None, Decimal, str]:
+ if product.product_type == CreditProductType.CREDIT_ADDON.value:
+ price = to_credit_decimal(product.price)
+ return price, None, price, ProductPriceType.REGULAR.value
+ base = to_credit_decimal(
+ product.regular_price if upgrade or not first_purchase else product.first_purchase_price
+ )
+ activity = activity_price_if_valid(product, request_time)
+ if activity is not None and activity < base:
+ return base, activity, activity, ProductPriceType.ACTIVITY.value
+ return base, activity, base, (
+ ProductPriceType.UPGRADE.value
+ if upgrade
+ else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
+ )
+
+
+async def get_active_subscription(
+ db: AsyncSession,
+ user_id: str,
+ *,
+ request_time: datetime | None = None,
+ for_update: bool = False,
+) -> UserCreditSubscription | None:
+ checked_at = request_time or utc_now()
+ stmt = (
+ select(UserCreditSubscription)
+ .where(
+ UserCreditSubscription.user_id == user_id,
+ UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
+ UserCreditSubscription.start_at <= checked_at,
+ UserCreditSubscription.expires_at > checked_at,
+ )
+ .order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
+ .limit(1)
+ )
+ if for_update:
+ stmt = stmt.with_for_update()
+ result = await db.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+async def get_upgrade_deduction_preview(
+ db: AsyncSession,
+ *,
+ subscription: UserCreditSubscription,
+ request_time: datetime,
+) -> Decimal:
+ if subscription.billing_cycle not in {
+ SubscriptionBillingCycle.QUARTERLY.value,
+ SubscriptionBillingCycle.YEARLY.value,
+ }:
+ return Decimal("0.00")
+ result = await db.execute(
+ select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
+ UserCreditSubscriptionPeriod.subscription_id == subscription.id,
+ UserCreditSubscriptionPeriod.scheduled_at > request_time,
+ UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ )
+ )
+ return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00"))
+
+
+async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
+ result = await db.execute(
+ select(CreditProduct)
+ .where(CreditProduct.is_active.is_(True))
+ .order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc())
+ )
+ return list(result.scalars().all())
+
+
+async def get_product(db: AsyncSession, product_id: str, *, for_update: bool = False) -> CreditProduct | None:
+ stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
+ if for_update:
+ stmt = stmt.with_for_update()
+ result = await db.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+async def build_product_catalog(
+ db: AsyncSession,
+ *,
+ user: User,
+ request_time: datetime | None = None,
+) -> dict:
+ checked_at = request_time or utc_now()
+ products = await list_active_products(db)
+ current = await get_active_subscription(db, user.id, request_time=checked_at)
+ first_purchase = user.first_membership_paid_at is None
+ subscription_products: list[dict] = []
+ credit_addons: list[dict] = []
+ upgrade_deduction = (
+ await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at)
+ if current is not None
+ else Decimal("0.00")
+ )
+
+ for product in products:
+ if product.product_type == CreditProductType.CREDIT_ADDON.value:
+ credit_addons.append(product_to_dict(product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True))
+ continue
+ # 首订资格已经使用后,未开启续费的套餐不返回给客户端。
+ # 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
+ # 避免仅靠客户端隐藏后仍可被直接构造请求购买。
+ if not first_purchase and not bool(product.renewal_enabled):
+ continue
+
+ can_purchase = current is None
+ can_upgrade = False
+ reason = None
+ if current is not None:
+ can_upgrade = (
+ product.billing_cycle == current.billing_cycle
+ and int(product.tier_rank or 0) > int(current.tier_rank or 0)
+ )
+ can_purchase = can_upgrade
+ if not can_upgrade:
+ reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
+ _, _, target_price, price_type = current_product_price(
+ product,
+ first_purchase=first_purchase,
+ request_time=checked_at,
+ upgrade=can_upgrade,
+ )
+ deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00")
+ user_price = max(Decimal("0.00"), target_price - deduction_amount)
+ if can_upgrade and user_price <= Decimal("0.00"):
+ can_purchase = False
+ reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理"
+ item = product_to_dict(
+ product,
+ user_price=user_price,
+ price_type=price_type,
+ can_purchase=can_purchase,
+ target_price=target_price,
+ deduction_amount=deduction_amount,
+ )
+ item["can_upgrade"] = can_upgrade
+ item["unavailable_reason"] = reason
+ subscription_products.append(item)
+
+ return {
+ "subscription_products": subscription_products,
+ "credit_addons": credit_addons,
+ "first_purchase_available": first_purchase,
+ "current_subscription": subscription_to_dict(current) if current else None,
+ }
+
+
+def product_to_dict(
+ product: CreditProduct,
+ *,
+ user_price: Decimal | None = None,
+ price_type: str | None = None,
+ can_purchase: bool | None = None,
+ target_price: Decimal | None = None,
+ deduction_amount: Decimal | None = None,
+) -> dict:
+ return {
+ "id": product.id,
+ "product_code": product.product_code,
+ "product_type": product.product_type,
+ "name": product.name,
+ "description": product.description,
+ "features": product.features_json or [],
+ "tier_code": product.tier_code,
+ "tier_rank": product.tier_rank,
+ "billing_cycle": product.billing_cycle,
+ "monthly_grant_credits": float(product.monthly_grant_credits or 0),
+ "grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1,
+ "first_purchase_price": float(product.first_purchase_price or 0),
+ "regular_price": float(product.regular_price or 0),
+ "activity_price": float(product.activity_price) if product.activity_price is not None else None,
+ "activity_start_at": product.activity_start_at,
+ "activity_end_at": product.activity_end_at,
+ "renewal_enabled": bool(product.renewal_enabled),
+ "grant_credits": float(product.grant_credits or 0),
+ "validity_months": 1 if product.is_credit_addon else None,
+ "price": float(user_price if user_price is not None else product.price),
+ "current_price": float(user_price if user_price is not None else product.price),
+ "target_price": float(target_price) if target_price is not None else None,
+ "deduction_amount": float(deduction_amount or Decimal("0.00")),
+ "price_type": price_type,
+ "credit_level": product.credit_level,
+ "currency": product.currency,
+ "is_active": product.is_active,
+ "sort_order": product.sort_order,
+ "can_purchase": can_purchase,
+ }
+
+
+def subscription_to_dict(subscription: UserCreditSubscription) -> dict:
+ return {
+ "id": subscription.id,
+ "product_id": subscription.product_id,
+ "status": subscription.status,
+ "purchase_scene": subscription.purchase_scene,
+ "tier_code": subscription.tier_code,
+ "tier_rank": subscription.tier_rank,
+ "billing_cycle": subscription.billing_cycle,
+ "anchor_at": subscription.anchor_at,
+ "start_at": subscription.start_at,
+ "expires_at": subscription.expires_at,
+ "monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
+ "grant_count": subscription.grant_count,
+ "granted_count": subscription.granted_count,
+ }
diff --git a/video-gen-api/app/services/credit/query_service.py b/video-gen-api/app/services/credit/query_service.py
new file mode 100644
index 00000000..ee202bcd
--- /dev/null
+++ b/video-gen-api/app/services/credit/query_service.py
@@ -0,0 +1,170 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from decimal import Decimal
+from typing import Iterable
+
+from sqlalchemy import and_, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_balance import CreditBalanceStatus
+from app.models.credit.balance import UserCreditBalance
+from app.services.credit.time_policy import last_usable_at
+from app.services.credit.utils import to_credit_decimal, to_float, utc_now
+
+
+@dataclass(slots=True, frozen=True)
+class CreditBalanceSummary:
+ available_credits: Decimal
+ next_expiring_credits: Decimal
+ next_expires_at: datetime | None
+ next_last_usable_at: datetime | None
+
+ def to_dict(self) -> dict:
+ return {
+ "available_credits": to_float(self.available_credits),
+ "credits": to_float(self.available_credits),
+ "next_expiring_credits": to_float(self.next_expiring_credits),
+ "next_expires_at": self.next_expires_at,
+ "next_last_usable_at": self.next_last_usable_at,
+ }
+
+
+async def get_available_credits(
+ db: AsyncSession,
+ user_id: str,
+ *,
+ request_time: datetime | None = None,
+) -> Decimal:
+ checked_at = request_time or utc_now()
+ result = await db.execute(
+ select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
+ UserCreditBalance.user_id == user_id,
+ UserCreditBalance.valid_from <= checked_at,
+ UserCreditBalance.expires_at > checked_at,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ )
+ return to_credit_decimal(result.scalar_one())
+
+
+async def get_balance_summary(
+ db: AsyncSession,
+ user_id: str,
+ *,
+ request_time: datetime | None = None,
+) -> CreditBalanceSummary:
+ checked_at = request_time or utc_now()
+ available = await get_available_credits(db, user_id, request_time=checked_at)
+ expiry_result = await db.execute(
+ select(
+ UserCreditBalance.expires_at,
+ func.sum(UserCreditBalance.unspent_amount).label("amount"),
+ )
+ .where(
+ UserCreditBalance.user_id == user_id,
+ UserCreditBalance.valid_from <= checked_at,
+ UserCreditBalance.expires_at > checked_at,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .group_by(UserCreditBalance.expires_at)
+ .order_by(UserCreditBalance.expires_at.asc())
+ .limit(1)
+ )
+ row = expiry_result.first()
+ expires_at = row.expires_at if row else None
+ expiring = to_credit_decimal(row.amount if row else 0)
+ return CreditBalanceSummary(
+ available_credits=available,
+ next_expiring_credits=expiring,
+ next_expires_at=expires_at,
+ next_last_usable_at=last_usable_at(expires_at) if expires_at else None,
+ )
+
+
+async def get_user_credit_map(
+ db: AsyncSession,
+ user_ids: Iterable[str],
+ *,
+ request_time: datetime | None = None,
+) -> dict[str, float]:
+ ids = list(dict.fromkeys(str(item) for item in user_ids if item))
+ if not ids:
+ return {}
+ checked_at = request_time or utc_now()
+ result = await db.execute(
+ select(
+ UserCreditBalance.user_id,
+ func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"),
+ )
+ .where(
+ UserCreditBalance.user_id.in_(ids),
+ UserCreditBalance.valid_from <= checked_at,
+ UserCreditBalance.expires_at > checked_at,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .group_by(UserCreditBalance.user_id)
+ )
+ output = {user_id: 0.0 for user_id in ids}
+ for row in result:
+ output[str(row.user_id)] = to_float(row.credits)
+ return output
+
+
+def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
+ # SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。
+ setattr(user, "credits", to_float(to_credit_decimal(credits)))
+ return user
+
+
+def effective_balance_status(balance: UserCreditBalance, *, request_time: datetime | None = None) -> str:
+ checked_at = request_time or utc_now()
+ if balance.revoked_at is not None or to_credit_decimal(balance.revoked_amount) > 0:
+ return CreditBalanceStatus.REVOKED.value
+ if balance.expires_at <= checked_at:
+ return CreditBalanceStatus.EXPIRED.value
+ if balance.valid_from > checked_at:
+ return CreditBalanceStatus.SCHEDULED.value
+ if to_credit_decimal(balance.unspent_amount) <= 0:
+ return CreditBalanceStatus.CONSUMED.value
+ return CreditBalanceStatus.ACTIVE.value
+
+
+def apply_balance_status_filter(stmt, status: str | None, *, request_time: datetime):
+ if not status:
+ return stmt
+ if status == CreditBalanceStatus.REVOKED.value:
+ return stmt.where(
+ or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0)
+ )
+ base_not_revoked = and_(
+ UserCreditBalance.revoked_at.is_(None),
+ UserCreditBalance.revoked_amount <= 0,
+ )
+ if status == CreditBalanceStatus.EXPIRED.value:
+ return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
+ if status == CreditBalanceStatus.SCHEDULED.value:
+ return stmt.where(
+ base_not_revoked,
+ UserCreditBalance.valid_from > request_time,
+ UserCreditBalance.expires_at > request_time,
+ )
+ if status == CreditBalanceStatus.CONSUMED.value:
+ return stmt.where(
+ base_not_revoked,
+ UserCreditBalance.valid_from <= request_time,
+ UserCreditBalance.expires_at > request_time,
+ UserCreditBalance.unspent_amount <= 0,
+ )
+ if status == CreditBalanceStatus.ACTIVE.value:
+ return stmt.where(
+ base_not_revoked,
+ UserCreditBalance.valid_from <= request_time,
+ UserCreditBalance.expires_at > request_time,
+ UserCreditBalance.unspent_amount > 0,
+ )
+ return stmt.where(UserCreditBalance.status == status)
diff --git a/video-gen-api/app/services/credit/subscription_service.py b/video-gen-api/app/services/credit/subscription_service.py
new file mode 100644
index 00000000..2fdd3e84
--- /dev/null
+++ b/video-gen-api/app/services/credit/subscription_service.py
@@ -0,0 +1,808 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_balance import (
+ CreditAllocationAction,
+ CreditBalanceSourceType,
+ CreditBalanceStatus,
+)
+from app.enums.credit_product import CreditProductType
+from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
+from app.enums.credit_subscription import (
+ CreditSubscriptionPeriodStatus,
+ CreditSubscriptionStatus,
+)
+from app.models.credit.allocation import CreditRecordAllocation
+from app.models.credit.balance import UserCreditBalance
+from app.models.credit.product import CreditProduct
+from app.models.credit.subscription import UserCreditSubscription
+from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
+from app.models.credit_record import CreditRecord
+from app.models.payment_order import PaymentOrder
+from app.models.user import User
+from app.services.credit.ledger_service import grant_credits
+from app.services.credit.locking import acquire_user_credit_lock
+from app.services.credit.product_service import grant_count_for_cycle
+from app.services.credit.query_service import get_available_credits
+from app.services.credit.time_policy import add_natural_months, natural_month_period
+from app.services.credit.utils import to_credit_decimal, utc_now
+from app.services.operation_log_service import log_operation_event
+from app.utils.id_gen import generate_id
+
+
+def _allocate_amount_by_period(total: Decimal, count: int) -> list[Decimal]:
+ cents = int((to_credit_decimal(total) * 100).to_integral_value())
+ base, remainder = divmod(cents, count)
+ values = [Decimal(base) / 100 for _ in range(count)]
+ values[-1] += Decimal(remainder) / 100
+ return [to_credit_decimal(item) for item in values]
+
+
+def _product_snapshot(product: CreditProduct) -> dict:
+ return {
+ "id": product.id,
+ "product_code": product.product_code,
+ "product_type": product.product_type,
+ "name": product.name,
+ "tier_code": product.tier_code,
+ "tier_rank": product.tier_rank,
+ "billing_cycle": product.billing_cycle,
+ "monthly_grant_credits": float(product.monthly_grant_credits or 0),
+ "first_purchase_price": float(product.first_purchase_price or 0),
+ "regular_price": float(product.regular_price or 0),
+ "activity_price": float(product.activity_price) if product.activity_price is not None else None,
+ "grant_credits": float(product.grant_credits or 0),
+ "credit_level": product.credit_level,
+ "features": product.features_json or [],
+ }
+
+
+def _resolve_order_product_snapshot(
+ order: PaymentOrder,
+ product: CreditProduct | None,
+) -> dict:
+ """返回支付创建时冻结的商品快照。
+
+ 支付履约不得读取后台后来修改后的价格、积分或套餐周期。旧订单若尚未
+ 保存快照,才允许使用当前商品生成一次兼容快照。
+ """
+ snapshot = dict(order.product_snapshot_json or {})
+ if not snapshot and product is not None:
+ snapshot = _product_snapshot(product)
+ if not snapshot:
+ raise ValueError("订单缺少商品快照,无法履约")
+ return snapshot
+
+
+def _snapshot_decimal(snapshot: dict, key: str) -> Decimal:
+ return to_credit_decimal(snapshot.get(key) or 0)
+
+
+async def _create_subscription(
+ db: AsyncSession,
+ *,
+ order: PaymentOrder,
+ product: CreditProduct | None,
+ paid_at: datetime,
+) -> tuple[UserCreditSubscription, list[UserCreditSubscriptionPeriod]]:
+ snapshot = _resolve_order_product_snapshot(order, product)
+ billing_cycle = str(snapshot.get("billing_cycle") or "")
+ count = grant_count_for_cycle(billing_cycle)
+ expires_at = add_natural_months(paid_at, count)
+ # 升级订单的现金实付额已扣除旧套餐未来周期价值;新订阅后续再升级时,
+ # 周期价值必须按目标套餐完整价格快照分摊,不能只按本次现金实付额分摊。
+ pricing_basis_amount = to_credit_decimal(
+ order.target_price_snapshot
+ if order.purchase_scene == "upgrade" and order.target_price_snapshot is not None
+ else order.amount
+ )
+ subscription = UserCreditSubscription(
+ id=generate_id(),
+ user_id=order.user_id,
+ product_id=order.product_id,
+ payment_order_id=order.id,
+ status=CreditSubscriptionStatus.ACTIVE.value,
+ purchase_scene=str(order.purchase_scene or "renewal"),
+ tier_code=str(snapshot.get("tier_code") or ""),
+ tier_rank=int(snapshot.get("tier_rank") or 0),
+ billing_cycle=billing_cycle,
+ anchor_at=paid_at,
+ start_at=paid_at,
+ expires_at=expires_at,
+ next_grant_at=(add_natural_months(paid_at, 1) if count > 1 else None),
+ monthly_grant_credits_snapshot=_snapshot_decimal(snapshot, "monthly_grant_credits"),
+ grant_count=count,
+ granted_count=0,
+ paid_amount_snapshot=pricing_basis_amount,
+ product_snapshot_json=snapshot,
+ source_subscription_id=order.source_subscription_id,
+ upgrade_order_id=(order.id if order.purchase_scene == "upgrade" else None),
+ )
+ db.add(subscription)
+ await db.flush()
+
+ allocations = _allocate_amount_by_period(pricing_basis_amount, count)
+ periods: list[UserCreditSubscriptionPeriod] = []
+ for sequence in range(count):
+ start, end = natural_month_period(paid_at, sequence)
+ period = UserCreditSubscriptionPeriod(
+ id=generate_id(),
+ subscription_id=subscription.id,
+ sequence=sequence + 1,
+ scheduled_at=start,
+ valid_from=start,
+ expires_at=end,
+ grant_credits=_snapshot_decimal(snapshot, "monthly_grant_credits"),
+ allocated_paid_amount=allocations[sequence],
+ status=CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ )
+ db.add(period)
+ periods.append(period)
+ await db.flush()
+ return subscription, periods
+
+
+async def grant_subscription_period(
+ db: AsyncSession,
+ *,
+ subscription: UserCreditSubscription,
+ period: UserCreditSubscriptionPeriod,
+ request_time: datetime | None = None,
+) -> UserCreditBalance:
+ checked_at = request_time or utc_now()
+ if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and period.issued_balance_id:
+ result = await db.execute(
+ select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
+ )
+ existing = result.scalar_one_or_none()
+ if existing:
+ return existing
+ if period.status not in {
+ CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ CreditSubscriptionPeriodStatus.GRANTED.value,
+ }:
+ raise ValueError("当前订阅周期不能发放积分")
+ mutation = await grant_credits(
+ db,
+ user_id=subscription.user_id,
+ amount=period.grant_credits,
+ description=f"订阅套餐第{period.sequence}个月积分发放",
+ source_type=CreditBalanceSourceType.SUBSCRIPTION_GRANT.value,
+ valid_from=period.valid_from,
+ expires_at=period.expires_at,
+ credit_level=str(subscription.product_snapshot_json.get("credit_level") or "general"),
+ source_id=period.id,
+ product_id=subscription.product_id,
+ payment_order_id=subscription.payment_order_id,
+ subscription_id=subscription.id,
+ subscription_period_id=period.id,
+ related_id=subscription.id,
+ record_type=CreditRecordType.RECHARGE.value,
+ biz_key=f"subscription:{subscription.id}:period:{period.sequence}:grant",
+ metadata_json={"period_sequence": period.sequence},
+ request_time=checked_at,
+ )
+ result = await db.execute(
+ select(UserCreditBalance)
+ .where(UserCreditBalance.grant_record_id == mutation.record.id)
+ .limit(1)
+ )
+ balance = result.scalar_one()
+ period.status = CreditSubscriptionPeriodStatus.GRANTED.value
+ period.issued_balance_id = balance.id
+ period.issued_at = checked_at
+ subscription.granted_count = max(subscription.granted_count, period.sequence)
+ subscription.next_grant_at = (
+ add_natural_months(subscription.anchor_at, period.sequence)
+ if period.sequence < subscription.grant_count
+ else None
+ )
+ await db.flush()
+ return balance
+
+
+async def _reconcile_upgrade_period_mismatch(
+ db: AsyncSession,
+ *,
+ order: PaymentOrder,
+ new_first_balance: UserCreditBalance,
+ checked_at: datetime,
+) -> None:
+ """核对升级订单创建时的抵扣周期与支付结算时状态。
+
+ 正常情况下抵扣周期一直处于 upgrade_reserved,仅取消未来发放。
+ 若历史竞态导致周期已发放,则撤销未消费积分,并把每一笔尚未退款的
+ 消费分摊通过 source_allocation_id 迁移到升级套餐首期积分,保证后续
+ 业务失败退款仍能准确退回当前实际承担成本的积分来源。
+ """
+ period_ids = list(order.upgrade_period_ids_json or [])
+ if not period_ids:
+ return
+ result = await db.execute(
+ select(UserCreditSubscriptionPeriod)
+ .where(UserCreditSubscriptionPeriod.id.in_(period_ids))
+ .order_by(UserCreditSubscriptionPeriod.id.asc())
+ .with_for_update()
+ )
+ periods = list(result.scalars().all())
+ expected_ids = [str(item) for item in period_ids]
+ if len(expected_ids) != len(set(expected_ids)):
+ raise RuntimeError("升级订单抵扣周期快照存在重复ID")
+ actual_ids = {str(period.id) for period in periods}
+ if actual_ids != set(expected_ids):
+ raise RuntimeError("升级订单抵扣周期快照与实际周期集合不一致")
+ if not order.source_subscription_id:
+ raise RuntimeError("升级订单缺少原订阅ID")
+ allowed_statuses = {
+ CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
+ CreditSubscriptionPeriodStatus.GRANTED.value,
+ }
+ for period in periods:
+ if str(period.subscription_id) != str(order.source_subscription_id):
+ raise RuntimeError("升级订单抵扣周期不属于原订阅")
+ if str(period.upgrade_order_id or "") != str(order.id):
+ raise RuntimeError("升级订单抵扣周期未绑定当前订单")
+ if period.status not in allowed_statuses:
+ raise RuntimeError(f"升级订单抵扣周期状态不允许结算:{period.status}")
+ if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and not period.issued_balance_id:
+ raise RuntimeError("升级订单抵扣周期已发放但缺少积分来源记录")
+ if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value and period.issued_balance_id:
+ raise RuntimeError("升级订单抵扣周期仍为预留状态但已存在积分来源记录")
+
+ reserved = [
+ period for period in periods
+ if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
+ ]
+ inconsistent = [
+ period
+ for period in periods
+ if period.status == CreditSubscriptionPeriodStatus.GRANTED.value
+ and period.issued_balance_id
+ ]
+ for period in reserved:
+ period.status = CreditSubscriptionPeriodStatus.CANCELLED_BY_UPGRADE.value
+ period.cancelled_at = checked_at
+ if not inconsistent:
+ return
+
+ balance_ids = [str(period.issued_balance_id) for period in inconsistent if period.issued_balance_id]
+ balance_result = await db.execute(
+ select(UserCreditBalance)
+ .where(UserCreditBalance.id.in_(balance_ids))
+ .order_by(UserCreditBalance.id.asc())
+ .with_for_update()
+ )
+ old_balances = list(balance_result.scalars().all())
+ if {str(item.id) for item in old_balances} != set(balance_ids):
+ raise RuntimeError("升级边界补偿缺少原积分批次")
+ period_by_balance = {str(period.issued_balance_id): period for period in inconsistent}
+ for balance in old_balances:
+ period = period_by_balance.get(str(balance.id))
+ if period is None:
+ raise RuntimeError("升级边界补偿积分批次与周期无法对应")
+ if str(balance.user_id) != str(order.user_id):
+ raise RuntimeError("升级边界补偿积分批次不属于当前用户")
+ if str(balance.subscription_id or "") != str(order.source_subscription_id):
+ raise RuntimeError("升级边界补偿积分批次不属于原订阅")
+ if str(balance.subscription_period_id or "") != str(period.id):
+ raise RuntimeError("升级边界补偿积分批次与订阅周期不一致")
+ total_transfer = sum(
+ (to_credit_decimal(item.consumed_amount) for item in old_balances),
+ Decimal("0.00"),
+ )
+ if to_credit_decimal(new_first_balance.unspent_amount) < total_transfer:
+ raise RuntimeError("升级套餐首期积分不足以承接边界消费来源迁移")
+
+ before_available = await get_available_credits(db, order.user_id, request_time=checked_at)
+ record = CreditRecord(
+ id=generate_id(),
+ user_id=order.user_id,
+ type=CreditRecordType.REVOKE.value,
+ amount=Decimal("0.00"),
+ balance_delta=Decimal("0.00"),
+ expired_amount=Decimal("0.00"),
+ balance_after=before_available,
+ description="套餐升级边界核对:废弃重复发放积分并迁移已消费来源",
+ related_id=order.id,
+ request_time=checked_at,
+ biz_key=f"payment-order:{order.id}:upgrade-reconcile",
+ billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
+ )
+ db.add(record)
+ await db.flush()
+
+ new_unspent_cursor = to_credit_decimal(new_first_balance.unspent_amount)
+ new_consumed_cursor = to_credit_decimal(new_first_balance.consumed_amount)
+ total_removed = Decimal("0.00")
+
+ for old in old_balances:
+ old_unspent = to_credit_decimal(old.unspent_amount)
+ old_consumed = to_credit_decimal(old.consumed_amount)
+ total_removed += old_unspent + old_consumed
+
+ if old_unspent > 0:
+ db.add(
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=old.id,
+ user_id=order.user_id,
+ allocation_action=CreditAllocationAction.REVOKE.value,
+ amount=old_unspent,
+ request_time=checked_at,
+ credit_level_snapshot=old.credit_level,
+ source_type_snapshot=old.source_type,
+ source_id_snapshot=old.source_id,
+ valid_from_snapshot=old.valid_from,
+ expires_at_snapshot=old.expires_at,
+ unspent_before=old_unspent,
+ unspent_after=Decimal("0.00"),
+ consumed_before=old_consumed,
+ consumed_after=old_consumed,
+ )
+ )
+
+ if old_consumed > 0:
+ consume_result = await db.execute(
+ select(CreditRecordAllocation)
+ .where(
+ CreditRecordAllocation.credit_balance_id == old.id,
+ CreditRecordAllocation.allocation_action
+ == CreditAllocationAction.CONSUME.value,
+ )
+ .order_by(CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
+ )
+ original_allocations = list(consume_result.scalars().all())
+ original_ids = [item.id for item in original_allocations]
+ adjusted_by_source: dict[str, Decimal] = {}
+ if original_ids:
+ adjusted_result = await db.execute(
+ select(CreditRecordAllocation).where(
+ CreditRecordAllocation.source_allocation_id.in_(original_ids),
+ CreditRecordAllocation.allocation_action.in_(
+ [
+ CreditAllocationAction.REFUND_AVAILABLE.value,
+ CreditAllocationAction.REFUND_EXPIRED.value,
+ CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
+ ]
+ ),
+ )
+ )
+ for adjusted in adjusted_result.scalars().all():
+ if adjusted.source_allocation_id:
+ adjusted_by_source[adjusted.source_allocation_id] = (
+ adjusted_by_source.get(adjusted.source_allocation_id, Decimal("0.00"))
+ + to_credit_decimal(adjusted.amount)
+ )
+ remaining = old_consumed
+ old_consumed_cursor = old_consumed
+ for original_allocation in original_allocations:
+ if remaining <= 0:
+ break
+ original_amount = to_credit_decimal(original_allocation.amount)
+ already_adjusted = adjusted_by_source.get(original_allocation.id, Decimal("0.00"))
+ available_to_transfer = original_amount - already_adjusted
+ if available_to_transfer < 0:
+ raise RuntimeError("消费分摊的退款或迁移金额超过原消费金额")
+ transfer_amount = min(available_to_transfer, remaining)
+ if transfer_amount <= 0:
+ continue
+ db.add(
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=old.id,
+ user_id=order.user_id,
+ source_allocation_id=original_allocation.id,
+ allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
+ amount=transfer_amount,
+ request_time=checked_at,
+ credit_level_snapshot=old.credit_level,
+ source_type_snapshot=old.source_type,
+ source_id_snapshot=old.source_id,
+ valid_from_snapshot=old.valid_from,
+ expires_at_snapshot=old.expires_at,
+ unspent_before=Decimal("0.00"),
+ unspent_after=Decimal("0.00"),
+ consumed_before=old_consumed_cursor,
+ consumed_after=old_consumed_cursor - transfer_amount,
+ )
+ )
+ db.add(
+ CreditRecordAllocation(
+ id=generate_id(),
+ credit_record_id=record.id,
+ credit_balance_id=new_first_balance.id,
+ user_id=order.user_id,
+ source_allocation_id=original_allocation.id,
+ allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
+ amount=transfer_amount,
+ request_time=checked_at,
+ credit_level_snapshot=new_first_balance.credit_level,
+ source_type_snapshot=new_first_balance.source_type,
+ source_id_snapshot=new_first_balance.source_id,
+ valid_from_snapshot=new_first_balance.valid_from,
+ expires_at_snapshot=new_first_balance.expires_at,
+ unspent_before=new_unspent_cursor,
+ unspent_after=new_unspent_cursor - transfer_amount,
+ consumed_before=new_consumed_cursor,
+ consumed_after=new_consumed_cursor + transfer_amount,
+ )
+ )
+ old_consumed_cursor -= transfer_amount
+ new_unspent_cursor -= transfer_amount
+ new_consumed_cursor += transfer_amount
+ remaining -= transfer_amount
+ if remaining > 0:
+ raise RuntimeError("无法定位全部已消费积分的原始业务分摊,升级结算已中止")
+
+ old.unspent_amount = Decimal("0.00")
+ old.consumed_amount = Decimal("0.00")
+ old.revoked_amount = to_credit_decimal(old.revoked_amount) + old_unspent + old_consumed
+ old.revoked_at = checked_at
+ old.status = CreditBalanceStatus.REVOKED.value
+ for period in inconsistent:
+ if period.issued_balance_id == old.id:
+ period.status = CreditSubscriptionPeriodStatus.REVOKED_BY_UPGRADE.value
+ period.revoked_at = checked_at
+
+ new_first_balance.unspent_amount = new_unspent_cursor
+ new_first_balance.consumed_amount = new_consumed_cursor
+ if new_unspent_cursor == 0:
+ new_first_balance.status = CreditBalanceStatus.CONSUMED.value
+
+ record.amount = -total_removed
+ record.balance_delta = -total_removed
+ record.balance_after = before_available - total_removed
+ await db.flush()
+
+
+async def fulfill_payment_product(
+ db: AsyncSession,
+ *,
+ order: PaymentOrder,
+ fulfilled_at: datetime | None = None,
+) -> None:
+ if order.fulfillment_status == "fulfilled":
+ return
+ checked_at = fulfilled_at or order.paid_at or utc_now()
+ await acquire_user_credit_lock(db, order.user_id)
+ user_result = await db.execute(select(User).where(User.id == order.user_id).with_for_update().limit(1))
+ user = user_result.scalar_one()
+ product_result = await db.execute(
+ select(CreditProduct).where(CreditProduct.id == order.product_id).limit(1)
+ )
+ product = product_result.scalar_one_or_none()
+ snapshot = _resolve_order_product_snapshot(order, product)
+ product_type = str(order.product_type or snapshot.get("product_type") or "")
+
+ if product_type == CreditProductType.CREDIT_ADDON.value:
+ await grant_credits(
+ db,
+ user_id=order.user_id,
+ amount=_snapshot_decimal(snapshot, "grant_credits"),
+ description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}",
+ source_type=CreditBalanceSourceType.CREDIT_ADDON.value,
+ valid_from=checked_at,
+ expires_at=add_natural_months(checked_at, 1),
+ credit_level=str(snapshot.get("credit_level") or "general"),
+ source_id=order.id,
+ product_id=order.product_id,
+ payment_order_id=order.id,
+ related_id=order.id,
+ biz_key=f"payment-order:{order.id}:credit-addon:grant",
+ request_time=checked_at,
+ )
+ order.fulfillment_status = "fulfilled"
+ order.fulfilled_at = checked_at
+ return
+
+ old_subscription: UserCreditSubscription | None = None
+ if order.purchase_scene == "upgrade":
+ if not order.source_subscription_id:
+ order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
+ await db.flush()
+ return
+ old_result = await db.execute(
+ select(UserCreditSubscription)
+ .where(UserCreditSubscription.id == order.source_subscription_id)
+ .with_for_update()
+ .limit(1)
+ )
+ old_subscription = old_result.scalar_one_or_none()
+ if old_subscription is None or str(old_subscription.user_id) != str(order.user_id):
+ order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
+ await db.flush()
+ return
+
+ if order.purchase_scene != "upgrade":
+ # 首充/续费维持原有支付履约语义:创建或发放异常继续向外抛出,
+ # 不能被升级专用的核对失败状态吞掉。
+ subscription, periods = await _create_subscription(
+ db,
+ order=order,
+ product=product,
+ paid_at=checked_at,
+ )
+ await grant_subscription_period(
+ db,
+ subscription=subscription,
+ period=periods[0],
+ request_time=checked_at,
+ )
+ else:
+ try:
+ async with db.begin_nested():
+ subscription, periods = await _create_subscription(
+ db,
+ order=order,
+ product=product,
+ paid_at=checked_at,
+ )
+ first_balance = await grant_subscription_period(
+ db,
+ subscription=subscription,
+ period=periods[0],
+ request_time=checked_at,
+ )
+ await _reconcile_upgrade_period_mismatch(
+ db,
+ order=order,
+ new_first_balance=first_balance,
+ checked_at=checked_at,
+ )
+ except Exception as exc:
+ order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
+ await db.flush()
+ log_operation_event(
+ domain="billing",
+ module="credit_upgrade",
+ event_type="CREDIT_UPGRADE_RECONCILE_FAILED",
+ event_status="failed",
+ source="app.services.credit.subscription_service.fulfill_payment_product",
+ user_id=str(order.user_id),
+ task_id=str(order.id),
+ message="套餐升级支付履约周期核对失败",
+ error=str(exc),
+ detail={
+ "order_id": str(order.id),
+ "order_no": str(order.order_no),
+ "source_subscription_id": str(order.source_subscription_id or ""),
+ "upgrade_period_ids": list(order.upgrade_period_ids_json or []),
+ },
+ )
+ return
+
+ if old_subscription is not None:
+ old_subscription.status = CreditSubscriptionStatus.UPGRADED.value
+ old_subscription.upgrade_order_id = order.id
+ order.subscription_id = subscription.id
+ order.fulfillment_status = "fulfilled"
+ order.fulfilled_at = checked_at
+ if user.first_membership_paid_at is None:
+ user.first_membership_paid_at = checked_at
+ await db.flush()
+
+
+async def list_due_subscription_period_candidates(
+ db: AsyncSession,
+ *,
+ request_time: datetime,
+ batch_size: int = 100,
+) -> list[tuple[str, str, str]]:
+ result = await db.execute(
+ select(
+ UserCreditSubscriptionPeriod.id,
+ UserCreditSubscriptionPeriod.subscription_id,
+ UserCreditSubscription.user_id,
+ )
+ .join(
+ UserCreditSubscription,
+ UserCreditSubscription.id == UserCreditSubscriptionPeriod.subscription_id,
+ )
+ .where(
+ UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
+ UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ UserCreditSubscriptionPeriod.scheduled_at <= request_time,
+ )
+ .order_by(UserCreditSubscriptionPeriod.scheduled_at.asc(), UserCreditSubscriptionPeriod.id.asc())
+ .limit(max(1, batch_size))
+ )
+ return [(str(row.id), str(row.subscription_id), str(row.user_id)) for row in result.all()]
+
+
+async def grant_due_subscription_period_by_id(
+ db: AsyncSession,
+ *,
+ period_id: str,
+ subscription_id: str,
+ user_id: str,
+ request_time: datetime,
+) -> bool:
+ await acquire_user_credit_lock(db, user_id)
+ subscription_result = await db.execute(
+ select(UserCreditSubscription)
+ .where(UserCreditSubscription.id == subscription_id)
+ .with_for_update()
+ .limit(1)
+ )
+ subscription = subscription_result.scalar_one_or_none()
+ if subscription is None or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
+ return False
+ period_result = await db.execute(
+ select(UserCreditSubscriptionPeriod)
+ .where(UserCreditSubscriptionPeriod.id == period_id)
+ .with_for_update()
+ .limit(1)
+ )
+ period = period_result.scalar_one_or_none()
+ if (
+ period is None
+ or period.subscription_id != subscription.id
+ or period.status != CreditSubscriptionPeriodStatus.SCHEDULED.value
+ or period.scheduled_at > request_time
+ ):
+ return False
+ await grant_subscription_period(
+ db,
+ subscription=subscription,
+ period=period,
+ request_time=request_time,
+ )
+ return True
+
+
+async def grant_due_subscription_periods(
+ db: AsyncSession,
+ *,
+ request_time: datetime | None = None,
+ batch_size: int = 100,
+) -> int:
+ checked_at = request_time or utc_now()
+ candidates = await list_due_subscription_period_candidates(
+ db, request_time=checked_at, batch_size=batch_size
+ )
+ count = 0
+ for period_id, subscription_id, user_id in candidates:
+ if await grant_due_subscription_period_by_id(
+ db,
+ period_id=period_id,
+ subscription_id=subscription_id,
+ user_id=user_id,
+ request_time=checked_at,
+ ):
+ count += 1
+ return count
+
+
+async def list_due_subscription_ids(
+ db: AsyncSession,
+ *,
+ request_time: datetime,
+ batch_size: int = 500,
+) -> list[str]:
+ result = await db.execute(
+ select(UserCreditSubscription.id)
+ .where(
+ UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
+ UserCreditSubscription.expires_at <= request_time,
+ )
+ .order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
+ .limit(max(1, batch_size))
+ )
+ return [str(item) for item in result.scalars().all()]
+
+
+async def expire_subscription_by_id(
+ db: AsyncSession,
+ *,
+ subscription_id: str,
+ request_time: datetime,
+) -> bool:
+ result = await db.execute(
+ select(UserCreditSubscription)
+ .where(UserCreditSubscription.id == subscription_id)
+ .with_for_update()
+ .limit(1)
+ )
+ subscription = result.scalar_one_or_none()
+ if (
+ subscription is None
+ or subscription.status != CreditSubscriptionStatus.ACTIVE.value
+ or subscription.expires_at > request_time
+ ):
+ return False
+ subscription.status = CreditSubscriptionStatus.EXPIRED.value
+ subscription.next_grant_at = None
+ await db.flush()
+ return True
+
+
+async def expire_due_subscriptions(
+ db: AsyncSession,
+ *,
+ request_time: datetime | None = None,
+ batch_size: int = 500,
+) -> int:
+ checked_at = request_time or utc_now()
+ ids = await list_due_subscription_ids(db, request_time=checked_at, batch_size=batch_size)
+ count = 0
+ for subscription_id in ids:
+ if await expire_subscription_by_id(
+ db, subscription_id=subscription_id, request_time=checked_at
+ ):
+ count += 1
+ return count
+
+
+async def revoke_payment_order_credits(
+ db: AsyncSession,
+ *,
+ order: PaymentOrder,
+ reason: str,
+ request_time: datetime | None = None,
+) -> float:
+ """支付现有退款流程的积分账本适配:只撤销该订单当前尚未消费的积分。"""
+ from app.services.credit.ledger_service import revoke_balances
+
+ checked_at = request_time or utc_now()
+ await acquire_user_credit_lock(db, order.user_id)
+ balance_result = await db.execute(
+ select(UserCreditBalance)
+ .where(
+ UserCreditBalance.payment_order_id == order.id,
+ UserCreditBalance.user_id == order.user_id,
+ UserCreditBalance.unspent_amount > 0,
+ UserCreditBalance.valid_from <= checked_at,
+ UserCreditBalance.expires_at > checked_at,
+ UserCreditBalance.revoked_at.is_(None),
+ )
+ .order_by(UserCreditBalance.id.asc())
+ .with_for_update()
+ )
+ balances = list(balance_result.scalars().all())
+ revoked = sum((to_credit_decimal(item.unspent_amount) for item in balances), Decimal("0.00"))
+ if balances:
+ await revoke_balances(
+ db,
+ balances=balances,
+ description=f"{reason}:撤销订单未消费积分",
+ related_id=order.id,
+ biz_key=f"payment-order:{order.id}:refund-revoke",
+ request_time=checked_at,
+ )
+
+ if order.subscription_id:
+ subscription_result = await db.execute(
+ select(UserCreditSubscription)
+ .where(UserCreditSubscription.id == order.subscription_id)
+ .limit(1)
+ .with_for_update()
+ )
+ subscription = subscription_result.scalar_one_or_none()
+ if subscription:
+ subscription.status = CreditSubscriptionStatus.REFUNDED.value
+ periods_result = await db.execute(
+ select(UserCreditSubscriptionPeriod)
+ .where(
+ UserCreditSubscriptionPeriod.subscription_id == subscription.id,
+ UserCreditSubscriptionPeriod.status.in_([
+ CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
+ ]),
+ )
+ .order_by(UserCreditSubscriptionPeriod.id.asc())
+ .with_for_update()
+ )
+ for period in periods_result.scalars().all():
+ period.status = CreditSubscriptionPeriodStatus.CANCELLED.value
+ period.cancelled_at = checked_at
+ period.upgrade_order_id = None
+ period.reserved_at = None
+ await db.flush()
+ return float(revoked)
diff --git a/video-gen-api/app/services/credit/time_policy.py b/video-gen-api/app/services/credit/time_policy.py
new file mode 100644
index 00000000..450b5506
--- /dev/null
+++ b/video-gen-api/app/services/credit/time_policy.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import calendar
+from datetime import datetime, time, timedelta
+from zoneinfo import ZoneInfo
+
+from app.services.credit.utils import ensure_aware
+
+BUSINESS_TZ = ZoneInfo("Asia/Shanghai")
+
+
+def to_business_time(value: datetime) -> datetime:
+ return ensure_aware(value).astimezone(BUSINESS_TZ)
+
+
+def add_natural_months(anchor_at: datetime, months: int) -> datetime:
+ """始终基于传入锚点计算自然月,月底压缩后下月恢复原锚点日。"""
+ anchor = to_business_time(anchor_at)
+ month_index = anchor.year * 12 + anchor.month - 1 + int(months)
+ year, month_zero = divmod(month_index, 12)
+ month = month_zero + 1
+ target_day = min(anchor.day, calendar.monthrange(year, month)[1])
+ return anchor.replace(year=year, month=month, day=target_day)
+
+
+def natural_month_period(anchor_at: datetime, sequence: int) -> tuple[datetime, datetime]:
+ start = add_natural_months(anchor_at, sequence)
+ end = add_natural_months(anchor_at, sequence + 1)
+ return start, end
+
+
+def next_local_midnight(value: datetime) -> datetime:
+ local = to_business_time(value)
+ next_day = local.date() + timedelta(days=1)
+ return datetime.combine(next_day, time.min, tzinfo=BUSINESS_TZ)
+
+
+def last_usable_at(expires_at: datetime) -> datetime:
+ return ensure_aware(expires_at) - timedelta(microseconds=1)
diff --git a/video-gen-api/app/services/credit/upgrade_service.py b/video-gen-api/app/services/credit/upgrade_service.py
new file mode 100644
index 00000000..1b98fa70
--- /dev/null
+++ b/video-gen-api/app/services/credit/upgrade_service.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from datetime import datetime
+from decimal import Decimal
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.credit_product import SubscriptionBillingCycle
+from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
+from app.models.credit.product import CreditProduct
+from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
+from app.models.payment_order import PaymentOrder
+from app.models.user import User
+from app.services.credit.locking import acquire_user_credit_lock
+from app.services.credit.product_service import (
+ ProductPriceQuote,
+ current_product_price,
+ get_active_subscription,
+)
+from app.services.credit.utils import to_credit_decimal
+
+
+async def quote_and_reserve_product_purchase(
+ db: AsyncSession,
+ *,
+ user: User,
+ product: CreditProduct,
+ order_id: str,
+ request_time: datetime,
+) -> ProductPriceQuote:
+ if product.is_credit_addon:
+ price = to_credit_decimal(product.price)
+ return ProductPriceQuote(
+ product=product,
+ purchase_scene="credit_addon",
+ price_type="regular",
+ base_price=price,
+ activity_price=None,
+ target_price=price,
+ deduction_amount=Decimal("0.00"),
+ payable_amount=price,
+ )
+
+ # 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期,
+ # 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。
+ await acquire_user_credit_lock(db, user.id)
+ pending_result = await db.execute(
+ select(PaymentOrder.id).where(
+ PaymentOrder.user_id == user.id,
+ PaymentOrder.id != order_id,
+ PaymentOrder.status == "pending",
+ PaymentOrder.product_type == "subscription",
+ ).limit(1)
+ )
+ if pending_result.scalar_one_or_none() is not None:
+ raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期")
+ current = await get_active_subscription(
+ db,
+ user.id,
+ request_time=request_time,
+ for_update=True,
+ )
+ first_purchase = user.first_membership_paid_at is None
+ if not first_purchase and not bool(product.renewal_enabled):
+ raise ValueError("该订阅套餐暂未开放续费或升级")
+ if current is None:
+ base, activity, target, price_type = current_product_price(
+ product,
+ first_purchase=first_purchase,
+ request_time=request_time,
+ upgrade=False,
+ )
+ return ProductPriceQuote(
+ product=product,
+ purchase_scene="first_purchase" if first_purchase else "renewal",
+ price_type=price_type,
+ base_price=base,
+ activity_price=activity,
+ target_price=target,
+ deduction_amount=Decimal("0.00"),
+ payable_amount=target,
+ )
+
+ if product.billing_cycle != current.billing_cycle:
+ raise ValueError("当前订阅有效,只能升级同周期更高等级套餐")
+ if int(product.tier_rank or 0) <= int(current.tier_rank or 0):
+ raise ValueError("当前订阅有效,不能提前续费或降级")
+
+ base, activity, target, price_type = current_product_price(
+ product,
+ first_purchase=False,
+ request_time=request_time,
+ upgrade=True,
+ )
+ period_ids: list[str] = []
+ periods: list[UserCreditSubscriptionPeriod] = []
+ deduction = Decimal("0.00")
+ if current.billing_cycle in {
+ SubscriptionBillingCycle.QUARTERLY.value,
+ SubscriptionBillingCycle.YEARLY.value,
+ }:
+ result = await db.execute(
+ select(UserCreditSubscriptionPeriod)
+ .where(
+ UserCreditSubscriptionPeriod.subscription_id == current.id,
+ UserCreditSubscriptionPeriod.scheduled_at > request_time,
+ UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
+ )
+ .order_by(UserCreditSubscriptionPeriod.sequence.asc())
+ .with_for_update()
+ )
+ periods = list(result.scalars().all())
+ for period in periods:
+ deduction += to_credit_decimal(period.allocated_paid_amount)
+ period_ids.append(period.id)
+ payable = target - deduction
+ if payable <= Decimal("0.00"):
+ raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理")
+ for period in periods:
+ period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
+ period.upgrade_order_id = order_id
+ period.reserved_at = request_time
+ return ProductPriceQuote(
+ product=product,
+ purchase_scene="upgrade",
+ price_type=price_type,
+ base_price=base,
+ activity_price=activity,
+ target_price=target,
+ deduction_amount=deduction,
+ payable_amount=payable,
+ source_subscription_id=current.id,
+ upgrade_period_ids=tuple(period_ids),
+ )
+
+
+async def release_upgrade_reservation(
+ db: AsyncSession,
+ *,
+ order: PaymentOrder,
+ released_at: datetime,
+) -> int:
+ period_ids = list(order.upgrade_period_ids_json or [])
+ if not period_ids:
+ return 0
+ await acquire_user_credit_lock(db, order.user_id)
+ result = await db.execute(
+ select(UserCreditSubscriptionPeriod)
+ .where(
+ UserCreditSubscriptionPeriod.id.in_(period_ids),
+ UserCreditSubscriptionPeriod.upgrade_order_id == order.id,
+ UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
+ )
+ .with_for_update()
+ )
+ periods = list(result.scalars().all())
+ for period in periods:
+ period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value
+ period.upgrade_order_id = None
+ period.reserved_at = None
+ await db.flush()
+ return len(periods)
diff --git a/video-gen-api/app/services/credit/utils.py b/video-gen-api/app/services/credit/utils.py
new file mode 100644
index 00000000..14d14cb2
--- /dev/null
+++ b/video-gen-api/app/services/credit/utils.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from decimal import Decimal, ROUND_HALF_UP
+from typing import Any
+
+CREDIT_QUANT = Decimal("0.01")
+
+
+def to_credit_decimal(value: Any) -> Decimal:
+ try:
+ return Decimal(str(value or 0)).quantize(CREDIT_QUANT, rounding=ROUND_HALF_UP)
+ except Exception as exc:
+ raise ValueError(f"无效积分数值: {value!r}") from exc
+
+
+def to_float(value: Decimal | int | float | None) -> float:
+ return float(to_credit_decimal(value))
+
+
+def utc_now() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+def ensure_aware(value: datetime) -> datetime:
+ if value.tzinfo is None:
+ return value.replace(tzinfo=timezone.utc)
+ return value
diff --git a/video-gen-api/app/services/credits.py b/video-gen-api/app/services/credits.py
index 6d377f12..d47ded76 100644
--- a/video-gen-api/app/services/credits.py
+++ b/video-gen-api/app/services/credits.py
@@ -1,5 +1,4 @@
-import math
-from dataclasses import dataclass
+from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,23 +8,11 @@ from app.models.credit_record import CreditRecord
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio
-from app.utils.id_gen import generate_id
-from app.utils.exceptions import InsufficientCreditsError
-from app.enums.common import BillingBlockEventEnum
-from app.services.operation_log_service import log_operation_event
-from app.services.system_config_cache import get_system_config_value
-from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
- """Calculate text credits based on actual token usage and cached configurable rate."""
- raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens")
- try:
- rate = float(raw_rate) if raw_rate not in (None, "") else 1.0
- except (TypeError, ValueError):
- rate = 1.0
- total_tokens = input_tokens + output_tokens
- return round(total_tokens * rate / 1000, 2)
+ """历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。"""
+ return 0.0
async def _get_credit_ratio(
@@ -176,31 +163,19 @@ async def calc_image_credits(
return round(total, 2)
-@dataclass(slots=True)
-class CreditMutationResult:
- user: User
- record: CreditRecord | None
- created: bool
- amount: float
- balance_before: float
- balance_after: float
-
-async def _get_existing_credit_record_by_biz_key(
- db: AsyncSession,
- *,
- user_id: str,
- biz_key: str | None,
-) -> CreditRecord | None:
- """按正式业务幂等键查找已有积分流水。"""
- if not biz_key:
- return None
- result = await db.execute(
- select(CreditRecord)
- .where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
- .limit(1)
- )
- return result.scalar_one_or_none()
+from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
+from app.enums.credit_record import CreditRecordType
+from app.services.credit.ledger_service import (
+ CreditMutationResult,
+ deduct_credits as deduct_dynamic_credits,
+ grant_credits,
+ refund_consumption,
+)
+from app.services.credit.query_service import get_available_credits
+from app.services.credit.time_policy import add_natural_months
+from app.services.credit.utils import to_float, utc_now
+from app.services.credit_record_meta_service import CreditRecordMeta
async def deduct_credits_result(
@@ -216,94 +191,21 @@ async def deduct_credits_result(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
+ request_time: datetime | None = None,
) -> CreditMutationResult:
- """并发安全且可观察幂等结果的积分扣减。"""
- amount = round(float(amount or 0), 2)
- result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
- user = result.scalar_one_or_none()
- if not user:
- raise ValueError("User not found")
-
- before_balance = round(float(user.credits or 0), 2)
- if biz_key:
- existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
- if existing:
- return CreditMutationResult(
- user=user,
- record=existing,
- created=False,
- amount=abs(round(float(existing.amount or 0), 2)),
- balance_before=before_balance,
- balance_after=before_balance,
- )
-
- if amount <= 0 and not create_zero_record:
- return CreditMutationResult(
- user=user,
- record=None,
- created=False,
- amount=0.0,
- balance_before=before_balance,
- balance_after=before_balance,
- )
-
- if amount > 0 and not allow_negative and before_balance < amount:
- event_type = (
- BillingBlockEventEnum.NEGATIVE_BALANCE.value
- if before_balance < 0
- else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value
- )
- log_operation_event(
- domain="billing",
- module="credits",
- event_type=event_type,
- event_status="failed",
- source="app.services.credits.deduct_credits_result",
- user_id=user_id,
- task_id=related_id,
- message="积分不足,已拦截新的扣费请求",
- detail={
- "user_id": user_id,
- "amount": amount,
- "before_balance": before_balance,
- "allow_negative": allow_negative,
- "biz_key": biz_key,
- "refund_for_biz_key": refund_for_biz_key,
- "description": description,
- "record_type": record_type,
- },
- )
- raise InsufficientCreditsError()
-
- user.credits = round(before_balance - max(0.0, amount), 2)
- meta_kwargs: dict = {}
- if record_meta:
- if isinstance(record_meta, CreditRecordMeta):
- record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
- meta_kwargs = record_meta.to_record_kwargs()
- elif isinstance(record_meta, dict):
- meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
- record = CreditRecord(
- id=generate_id(),
+ """旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
+ return await deduct_dynamic_credits(
+ db,
user_id=user_id,
- type=record_type,
- amount=-amount,
- balance_after=user.credits,
+ amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
- **meta_kwargs,
- )
- db.add(record)
- await db.flush()
- return CreditMutationResult(
- user=user,
- record=record,
- created=True,
- amount=amount,
- balance_before=before_balance,
- balance_after=round(float(user.credits or 0), 2),
+ record_meta=record_meta,
+ record_type=record_type,
+ create_zero_record=create_zero_record,
+ request_time=request_time,
)
@@ -320,22 +222,24 @@ async def deduct_credits(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
+ request_time: datetime | None = None,
) -> User:
- """兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。"""
- mutation = await deduct_credits_result(
- db,
- user_id=user_id,
- amount=amount,
- description=description,
- related_id=related_id,
- biz_key=biz_key,
- refund_for_biz_key=refund_for_biz_key,
- record_meta=record_meta,
- record_type=record_type,
- allow_negative=allow_negative,
- create_zero_record=create_zero_record,
- )
- return mutation.user
+ return (
+ await deduct_credits_result(
+ db,
+ user_id=user_id,
+ amount=amount,
+ description=description,
+ related_id=related_id,
+ biz_key=biz_key,
+ refund_for_biz_key=refund_for_biz_key,
+ record_meta=record_meta,
+ record_type=record_type,
+ allow_negative=allow_negative,
+ create_zero_record=create_zero_record,
+ request_time=request_time,
+ )
+ ).user
async def add_credits_result(
@@ -349,66 +253,63 @@ async def add_credits_result(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
+ valid_from: datetime | None = None,
+ expires_at: datetime | None = None,
+ credit_level: str = CreditLevel.GENERAL.value,
+ source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
+ source_id: str | None = None,
+ product_id: str | None = None,
+ payment_order_id: str | None = None,
+ subscription_id: str | None = None,
+ subscription_period_id: str | None = None,
+ request_time: datetime | None = None,
) -> CreditMutationResult:
- """并发安全且可观察幂等结果的积分增加。"""
- amount = round(float(amount or 0), 2)
- result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
- user = result.scalar_one_or_none()
- if not user:
- raise ValueError("User not found")
-
- before_balance = round(float(user.credits or 0), 2)
- if biz_key:
- existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
- if existing:
- return CreditMutationResult(
- user=user,
- record=existing,
- created=False,
- amount=abs(round(float(existing.amount or 0), 2)),
- balance_before=before_balance,
- balance_after=before_balance,
- )
-
- if amount <= 0:
+ checked_at = request_time or utc_now()
+ if record_type == CreditRecordType.REFUND.value and refund_for_biz_key:
+ refunded = await refund_consumption(
+ db,
+ user_id=user_id,
+ refund_for_biz_key=refund_for_biz_key,
+ description=description,
+ related_id=related_id,
+ biz_key=biz_key,
+ record_meta=record_meta,
+ refund_time=checked_at,
+ )
+ user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
+ user = user_result.scalar_one()
+ setattr(user, "credits", to_float(refunded.balance_after))
+ record = refunded.records[0] if refunded.records else None
return CreditMutationResult(
user=user,
- record=None,
- created=False,
- amount=0.0,
- balance_before=before_balance,
- balance_after=before_balance,
+ record=record,
+ created=refunded.created,
+ amount=to_float(refunded.total_amount),
+ balance_before=to_float(refunded.balance_before),
+ balance_after=to_float(refunded.balance_after),
+ refund_available=to_float(refunded.available_amount),
+ refund_expired=to_float(refunded.expired_amount),
)
-
- user.credits = round(before_balance + amount, 2)
- meta_kwargs: dict = {}
- if record_meta:
- if isinstance(record_meta, CreditRecordMeta):
- record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
- meta_kwargs = record_meta.to_record_kwargs()
- elif isinstance(record_meta, dict):
- meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
- record = CreditRecord(
- id=generate_id(),
+ starts_at = valid_from or checked_at
+ return await grant_credits(
+ db,
user_id=user_id,
- type=record_type,
amount=amount,
- balance_after=user.credits,
description=description,
+ source_type=source_type,
+ valid_from=starts_at,
+ expires_at=expires_at or add_natural_months(starts_at, 1),
+ credit_level=credit_level,
+ source_id=source_id or related_id,
+ product_id=product_id,
+ payment_order_id=payment_order_id,
+ subscription_id=subscription_id,
+ subscription_period_id=subscription_period_id,
related_id=related_id,
+ record_type=record_type,
biz_key=biz_key,
- refund_for_biz_key=refund_for_biz_key,
- **meta_kwargs,
- )
- db.add(record)
- await db.flush()
- return CreditMutationResult(
- user=user,
- record=record,
- created=True,
- amount=amount,
- balance_before=before_balance,
- balance_after=round(float(user.credits or 0), 2),
+ record_meta=record_meta,
+ request_time=checked_at,
)
@@ -423,20 +324,40 @@ async def add_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
+ valid_from: datetime | None = None,
+ expires_at: datetime | None = None,
+ credit_level: str = CreditLevel.GENERAL.value,
+ source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
+ source_id: str | None = None,
+ product_id: str | None = None,
+ payment_order_id: str | None = None,
+ subscription_id: str | None = None,
+ subscription_period_id: str | None = None,
+ request_time: datetime | None = None,
) -> User:
- """兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。"""
- mutation = await add_credits_result(
- db,
- user_id=user_id,
- amount=amount,
- description=description,
- related_id=related_id,
- record_type=record_type,
- biz_key=biz_key,
- refund_for_biz_key=refund_for_biz_key,
- record_meta=record_meta,
- )
- return mutation.user
+ return (
+ await add_credits_result(
+ db,
+ user_id=user_id,
+ amount=amount,
+ description=description,
+ related_id=related_id,
+ record_type=record_type,
+ biz_key=biz_key,
+ refund_for_biz_key=refund_for_biz_key,
+ record_meta=record_meta,
+ valid_from=valid_from,
+ expires_at=expires_at,
+ credit_level=credit_level,
+ source_type=source_type,
+ source_id=source_id,
+ product_id=product_id,
+ payment_order_id=payment_order_id,
+ subscription_id=subscription_id,
+ subscription_period_id=subscription_period_id,
+ request_time=request_time,
+ )
+ ).user
async def refund_credits(
@@ -449,30 +370,41 @@ async def refund_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
+ request_time: datetime | None = None,
) -> User:
- """生成失败积分回退。"""
+ if not refund_for_biz_key:
+ raise ValueError("动态积分退款必须指定原消费 biz_key")
return await add_credits(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
- record_type="refund",
+ record_type=CreditRecordType.REFUND.value,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
+ request_time=request_time,
)
-async def get_records(db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20) -> tuple[list[CreditRecord], int]:
+async def get_credit_balance(db: AsyncSession, user_id: str, request_time: datetime | None = None) -> float:
+ return to_float(await get_available_credits(db, user_id, request_time=request_time or utc_now()))
+
+
+async def get_records(
+ db: AsyncSession,
+ user_id: str,
+ page: int = 1,
+ page_size: int = 20,
+) -> tuple[list[CreditRecord], int]:
count_query = select(func.count(CreditRecord.id)).where(CreditRecord.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
-
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
- .order_by(CreditRecord.created_at.desc())
+ .order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
- return list(result.scalars().all()), total
+ return list(result.scalars().all()), int(total)
diff --git a/video-gen-api/app/services/generation/billing_service.py b/video-gen-api/app/services/generation/billing_service.py
index f1d8c6bc..3b5262d8 100644
--- a/video-gen-api/app/services/generation/billing_service.py
+++ b/video-gen-api/app/services/generation/billing_service.py
@@ -38,7 +38,7 @@ OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.va
OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value
_BIZ_KEY_PATTERN = re.compile(
- r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|hold|hold_release)$"
+ r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund|pre_deduct|hold|hold_release)$"
)
@@ -101,8 +101,8 @@ def build_credit_biz_key(
owner_id = owner_id.strip()
charge_kind = charge_kind.strip()
action = action.strip()
- if action not in ("charge", "refund", "hold", "hold_release"):
- raise ValueError("action 仅支持 charge/refund/hold/hold_release")
+ if action not in ("charge", "refund", "pre_deduct", "hold", "hold_release"):
+ raise ValueError("action 仅支持 charge/refund/pre_deduct/hold/hold_release")
if attempt_no <= 0:
raise ValueError("attempt_no 必须大于 0")
return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}"
diff --git a/video-gen-api/app/services/generation/prompt_optimize_service.py b/video-gen-api/app/services/generation/prompt_optimize_service.py
index e7bb75d2..18b11de6 100644
--- a/video-gen-api/app/services/generation/prompt_optimize_service.py
+++ b/video-gen-api/app/services/generation/prompt_optimize_service.py
@@ -28,7 +28,6 @@ from app.enums.generation_status import (
GenerationStatus,
GenerationType,
)
-from app.enums.llm_billing import LlmBillingConfigKey
from app.models.generation_record import GenerationRecord
from app.models.project import Project
from app.schemas.generation import OptimizeParams
@@ -51,12 +50,12 @@ from app.services.generation.pipeline.generation_record_config_service import (
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
- log_provider_failure,
+ record_provider_exception,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
- start_hold,
+ finalize_llm_business_failure,
+ mark_business_success,
+ pre_deduct,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
@@ -110,7 +109,6 @@ def _billing_context(*, user_id: str, record_id: str, request_id: str | None) ->
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
related_id=record_id,
- hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
description_prefix="AI创作提示词优化",
trace_id=f"generation-optimize:{record_id}",
request_id=request_id,
@@ -246,14 +244,14 @@ async def _settle_staged_result(
detail={"status": status_snapshot, "attempt_no": _PROMPT_ATTEMPT_NO},
)
try:
- billing = await settle_success(
+ billing = await mark_business_success(
db,
ctx,
usage=usage,
description=f"提示词优化 - {project_name}",
)
charge_item = next(
- (item for item in billing.items if item.biz_key == ctx.charge_biz_key),
+ (item for item in billing.items if item.biz_key == ctx.billing_biz_key),
None,
)
record.text_credits_cost = round(float(charge_item.amount if charge_item else 0.0), 2)
@@ -468,7 +466,7 @@ async def optimize_generation_prompt(
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key)
try:
- await start_hold(db, ctx)
+ await pre_deduct(db, ctx)
await db.commit()
except Exception:
await db.rollback()
@@ -488,7 +486,7 @@ async def optimize_generation_prompt(
},
)
- log_provider_start(ctx, detail={"gen_type": req.gen_type.value})
+ await log_provider_start(db, ctx, detail={"gen_type": req.gen_type.value})
try:
optimized_prompt, usage = await optimize_prompt(
db,
@@ -507,11 +505,15 @@ async def optimize_generation_prompt(
log_owner_type=OWNER_GENERATION_RECORD,
log_owner_id=record_id,
generation_attempt_no=_PROMPT_ATTEMPT_NO,
+ fixed_model_config_id=ctx.model_config_id,
+ fixed_model_snapshot=ctx.model_parameters_snapshot,
)
- log_provider_success(ctx, usage=usage)
+ await log_provider_success(db, ctx, usage=usage)
except Exception as exc:
await db.rollback()
- log_provider_failure(ctx, error=str(exc))
+ provider_succeeded, recovered_usage = await record_provider_exception(db, ctx, exc)
+ if recovered_usage:
+ usage = recovered_usage
compensated = False
try:
failed_result = await db.execute(
@@ -521,12 +523,21 @@ async def optimize_generation_prompt(
.limit(1)
)
failed_record = failed_result.scalar_one_or_none()
+ business_already_succeeded = bool(
+ failed_record is not None
+ and failed_record.status in {
+ GenerationStatus.prompt_optimized.value,
+ GenerationStatus.generating.value,
+ GenerationStatus.completed.value,
+ }
+ )
if failed_record is not None and failed_record.status == GenerationStatus.optimizing.value:
failed_record.status = GenerationStatus.failed.value
failed_record.error_message = extract_error_message(exc, "提示词")
- await release_on_failure(db, ctx, error=str(exc))
- compensated = True
await db.commit()
+ if not business_already_succeeded:
+ await finalize_llm_business_failure(ctx, error=str(exc))
+ compensated = True
except Exception:
await db.rollback()
logger.exception("prompt optimize failure compensation failed: record_id=%s", record_id)
@@ -613,6 +624,7 @@ async def optimize_generation_prompt(
await db.rollback()
logger.exception("prompt optimize provider result staging failed: record_id=%s", record_id)
if not staged:
+ failure = last_stage_error or RuntimeError("unknown staging failure")
log_operation_error(
domain=_LOG_DOMAIN,
event_type=GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED.value,
@@ -622,9 +634,45 @@ async def optimize_generation_prompt(
project_id=project_id,
task_id=record_id,
detail={"attempt_no": _PROMPT_ATTEMPT_NO, "stage": "provider_result_persistence"},
- exc=last_stage_error or RuntimeError("unknown staging failure"),
+ exc=failure,
)
- raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,请联系管理员根据模型日志处理")
+ # 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后,
+ # 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按
+ # 原积分来源退回固定预扣,不能让账务永久停留在 processing。
+ business_already_succeeded = False
+ try:
+ await db.rollback()
+ failed_result = await db.execute(
+ select(GenerationRecord)
+ .where(
+ GenerationRecord.id == record_id,
+ GenerationRecord.user_id == user_id,
+ GenerationRecord.deleted_at.is_(None),
+ )
+ .with_for_update()
+ .limit(1)
+ )
+ failed_record = failed_result.scalar_one_or_none()
+ business_already_succeeded = bool(
+ failed_record is not None
+ and failed_record.status in {
+ GenerationStatus.prompt_optimized.value,
+ GenerationStatus.generating.value,
+ GenerationStatus.completed.value,
+ }
+ )
+ if failed_record is not None and not business_already_succeeded:
+ failed_record.status = GenerationStatus.failed.value
+ failed_record.pipeline_stage = None
+ failed_record.error_message = "提词已生成但本地结果暂存失败"
+ await db.commit()
+ except Exception:
+ await db.rollback()
+ logger.exception("prompt optimize staging final failure persistence failed: record_id=%s", record_id)
+ if business_already_succeeded:
+ return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
+ await finalize_llm_business_failure(ctx, error=str(failure))
+ raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,固定预扣积分已按原来源退回")
_log_event(
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED,
diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py
index 94b4dfa8..ed9dad95 100644
--- a/video-gen-api/app/services/hot_opening_replicate_service.py
+++ b/video-gen-api/app/services/hot_opening_replicate_service.py
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -55,13 +54,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
- ensure_hold_exists,
+ ensure_pre_deducted,
log_provider_failure,
+ record_provider_exception,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
- start_hold,
+ refund_on_final_failure,
+ finalize_llm_business_failure,
+ mark_business_success,
+ pre_deduct,
)
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
@@ -892,7 +893,7 @@ async def submit_image_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.error_message = None
- await start_hold(
+ await pre_deduct(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -906,9 +907,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="爆款开头复刻图片AI提词优化",
- trace_id=f"llm-submit-hold:{step.id}",
+ trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -1005,14 +1005,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
- description_prefix="爆款开头复刻图片AI提词优化",
+ description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"hot-opening-image-prompt:{step_id_value}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
- step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
+ step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1023,7 +1022,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
- log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
+ await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -1048,9 +1047,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=token_usage)
+ await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1064,16 +1065,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
- # Provider 已成功,旧步骤即使失效也必须按真实 usage 结算,不能免费释放。
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-图片AI提词优化(失效结果结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
- billing = await settle_success(
+ billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1121,21 +1116,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
- # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-图片AI提词优化(行锁失败结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
+ await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
- if not provider_succeeded:
- log_provider_failure(llm_billing_context, error=str(exc))
+ if provider_succeeded:
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
+ if recovered_usage:
+ token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1150,16 +1143,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-图片AI提词优化(异常失效结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
- await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1178,16 +1162,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-图片AI提词优化(本地失败结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1351,7 +1327,7 @@ async def submit_video_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.error_message = None
- await start_hold(
+ await pre_deduct(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1365,9 +1341,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="爆款开头复刻视频AI提词优化",
- trace_id=f"llm-submit-hold:{step.id}",
+ trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1462,14 +1437,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
- description_prefix="爆款开头复刻视频AI提词优化",
+ description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"hot-opening-video-prompt:{step_id_value}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
- step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
+ step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1480,7 +1454,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
- log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
+ await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "无",
@@ -1517,9 +1491,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"hot-video-prompt:{step_id_value}",
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=token_usage)
+ await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1533,15 +1509,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-视频AI提词优化(失效结果结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
- billing = await settle_success(
+ billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1592,21 +1563,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
- # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-视频AI提词优化(行锁失败结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
+ await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
- if not provider_succeeded:
- log_provider_failure(llm_billing_context, error=str(exc))
+ if provider_succeeded:
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
+ if recovered_usage:
+ token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1621,16 +1590,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-视频AI提词优化(异常失效结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
- await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1649,16 +1609,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="爆款开头复刻-视频AI提词优化(本地失败结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1946,7 +1898,7 @@ async def mark_hot_opening_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
- await release_on_failure(
+ await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1964,11 +1916,6 @@ async def mark_hot_opening_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
- hold_config_key=(
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
- if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
- else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
- ),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py
index 98445d03..cf11750f 100644
--- a/video-gen-api/app/services/hot_opening_video_prompt_service.py
+++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py
@@ -15,6 +15,7 @@ from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
+from app.services.llm_billing.context import LlmProviderPostprocessError
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_VERSION,
@@ -1545,8 +1546,15 @@ def _log_video_prompt_ai_event(
**common,
)
-async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
- result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)).order_by(ModelConfig.priority.desc()).limit(1))
+async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
+ stmt = select(ModelConfig).where(
+ ModelConfig.is_active == True,
+ ModelConfig.deleted_at.is_(None),
+ ModelConfig.provider != "mock",
+ )
+ if model_config_id:
+ stmt = stmt.where(ModelConfig.id == model_config_id)
+ result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -1566,6 +1574,8 @@ async def optimize_hot_opening_video_prompt(
project_id: str | None = None,
step_id: str | None = None,
trace_id: str | None = None,
+ fixed_model_config_id: str | None = None,
+ fixed_model_snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
call_id = generate_id()
duration = int(video_config["duration"])
@@ -1580,15 +1590,18 @@ async def optimize_hot_opening_video_prompt(
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
- config_row = await _select_model_config(db)
+ config_row = await _select_model_config(db, fixed_model_config_id)
+ snapshot = dict(fixed_model_snapshot or {})
config = (
SimpleNamespace(
id=str(config_row.id),
- name=str(config_row.name or ""),
- provider=str(config_row.provider or ""),
- api_base=str(config_row.api_base or ""),
+ name=str(snapshot.get("name") or config_row.name or ""),
+ provider=str(snapshot.get("provider") or config_row.provider or ""),
+ api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
- model_name=str(config_row.model_name or ""),
+ model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
+ max_tokens=snapshot.get("max_tokens"),
+ temperature=snapshot.get("temperature"),
)
if config_row is not None
else None
@@ -1597,14 +1610,7 @@ async def optimize_hot_opening_video_prompt(
# 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。
await db.rollback()
if not config:
- result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
- return result, build_final_video_prompt(result), {
- "input_tokens": 0,
- "output_tokens": 0,
- "total_tokens": 0,
- "usage_reported": True,
- "billing_free": True,
- }
+ raise RuntimeError("固定LLM模型不存在、已停用或为Mock模型,禁止自动切换和降级")
if use_base64:
video_url_final = await media_to_base64(material_video_url, "video/mp4")
@@ -1720,13 +1726,10 @@ async def optimize_hot_opening_video_prompt(
try:
data = response.json()
- content = data["choices"][0]["message"]["content"].strip()
- if not content:
- raise RuntimeError("视频提词模型响应 content 为空")
except Exception as exc:
- parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
+ parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
_log_video_prompt_ai_event(
- call_id=call_id,
+ call_id=call_id,
module=module,
event_type=parse_event,
action=remote_action,
@@ -1740,7 +1743,7 @@ async def optimize_hot_opening_video_prompt(
response_data=response_data,
http_status=response.status_code,
remote_request_id=remote_request_id,
- message="视频提词模型响应解析失败",
+ message="视频提词模型响应 JSON 解析失败",
error=str(exc),
)
raise
@@ -1765,6 +1768,9 @@ async def optimize_hot_opening_video_prompt(
})
try:
+ content = data["choices"][0]["message"]["content"].strip()
+ if not content:
+ raise RuntimeError("视频提词模型响应 content 为空")
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
except Exception as exc:
@@ -1787,7 +1793,7 @@ async def optimize_hot_opening_video_prompt(
message="视频提词业务 JSON 解析失败",
error=str(exc),
)
- raise
+ raise LlmProviderPostprocessError(f"视频提词响应后处理失败: {exc}", usage=token_usage) from exc
success_event, remote_action = _video_prompt_remote_event(module, success=True)
_log_video_prompt_ai_event(
call_id=call_id,
diff --git a/video-gen-api/app/services/llm.py b/video-gen-api/app/services/llm.py
index fe7ac339..c8fa656a 100644
--- a/video-gen-api/app/services/llm.py
+++ b/video-gen-api/app/services/llm.py
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
+from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
@@ -74,66 +75,52 @@ async def optimize_prompt(
log_owner_type: str | None = None,
log_owner_id: str | None = None,
generation_attempt_no: int | None = None,
+ fixed_model_config_id: str | None = None,
+ fixed_model_snapshot: dict | None = None,
) -> tuple[str, dict]:
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
- result = await db.execute(
- select(ModelConfig)
- .where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
- .order_by(ModelConfig.priority.desc())
+ stmt = select(ModelConfig).where(
+ ModelConfig.is_active.is_(True),
+ ModelConfig.deleted_at.is_(None),
+ ModelConfig.provider != "mock",
+ )
+ if fixed_model_config_id:
+ stmt = stmt.where(ModelConfig.id == fixed_model_config_id)
+ stmt = stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)
+ result = await db.execute(stmt)
+ item = result.scalar_one_or_none()
+ if item is None:
+ raise LLMProviderCallError("没有可用的固定LLM模型,本版本禁止Mock、自动切换和降级")
+ snapshot = dict(fixed_model_snapshot or {})
+ selected = SimpleNamespace(
+ id=item.id,
+ name=snapshot.get("name") or item.name,
+ provider=snapshot.get("provider") or item.provider,
+ api_base=snapshot.get("api_base") or item.api_base,
+ api_key=item.api_key,
+ model_name=snapshot.get("model_name") or item.model_name,
+ max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else item.max_tokens,
+ temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else item.temperature,
)
- configs = [
- SimpleNamespace(
- id=item.id,
- name=item.name,
- provider=item.provider,
- api_base=item.api_base,
- api_key=item.api_key,
- model_name=item.model_name,
- max_tokens=item.max_tokens,
- temperature=item.temperature,
- )
- for item in result.scalars().all()
- ]
- # Release the read transaction before the external LLM request. Only
- # plain scalar snapshots are used afterwards, so expire_on_commit does
- # not trigger an ORM refresh while the provider request is in flight.
await db.commit()
-
- if configs:
- # 按 priority 从大到小依次尝试,跳过 mock,失败则用下一个
- for selected in configs:
- if selected.provider == "mock":
- continue
- if selected.provider in ("openai_compatible", "sdk"):
- try:
- return await _call_openai_compatible(
- selected, original_prompt, db, user_id, industry_key, duration,
- references=references,
- gen_type=gen_type,
- image_size=image_size,
- image_proportion=image_proportion,
- image_px=image_px,
- log_module=log_module,
- log_step=log_step,
- log_project_id=log_project_id,
- log_task_id=log_task_id,
- log_owner_type=log_owner_type,
- log_owner_id=log_owner_id,
- generation_attempt_no=generation_attempt_no,
- )
- except LLMProviderCallError:
- continue
-
- # 所有真实模型都失败,降级到 mock
- mock_cfg = next((c for c in configs if c.provider == "mock"), None)
- if mock_cfg:
- return _mock_optimize(original_prompt, gen_type)
-
- if settings.LLM_MOCK:
- return _mock_optimize(original_prompt, gen_type)
-
- return _get_default_prompt(original_prompt, gen_type)
+ if selected.provider not in ("openai_compatible", "sdk"):
+ raise LLMProviderCallError(f"固定模型供应商不受支持:{selected.provider}")
+ return await _call_openai_compatible(
+ selected, original_prompt, db, user_id, industry_key, duration,
+ references=references,
+ gen_type=gen_type,
+ image_size=image_size,
+ image_proportion=image_proportion,
+ image_px=image_px,
+ log_module=log_module,
+ log_step=log_step,
+ log_project_id=log_project_id,
+ log_task_id=log_task_id,
+ log_owner_type=log_owner_type,
+ log_owner_id=log_owner_id,
+ generation_attempt_no=generation_attempt_no,
+ )
def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
@@ -435,19 +422,28 @@ async def _call_openai_compatible(
)
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
+ raw_usage = data.get("usage") if isinstance(data, dict) else None
+ usage_reported = bool(
+ isinstance(raw_usage, dict)
+ and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens"))
+ )
+ usage = raw_usage if isinstance(raw_usage, dict) else {}
+ input_tokens = int(usage.get("prompt_tokens", 0) or 0)
+ output_tokens = int(usage.get("completion_tokens", 0) or 0)
+ total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
+ token_usage = {
+ "model_config_id": config.id,
+ "model_config_name": config.name,
+ "model_provider": config.provider,
+ "model_name": config.model_name,
+ "source_module": log_module,
+ "source_step_code": log_step,
+ "input_tokens": input_tokens,
+ "output_tokens": output_tokens,
+ "total_tokens": total_tokens,
+ "usage_reported": usage_reported,
+ }
try:
- raw_usage = data.get("usage")
- usage_reported = bool(
- isinstance(raw_usage, dict)
- and any(
- key in raw_usage
- for key in ("prompt_tokens", "completion_tokens", "total_tokens")
- )
- )
- usage = raw_usage if isinstance(raw_usage, dict) else {}
- input_tokens = int(usage.get("prompt_tokens", 0) or 0)
- output_tokens = int(usage.get("completion_tokens", 0) or 0)
- total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise ValueError("模型未返回有效提示词")
@@ -461,18 +457,5 @@ async def _call_openai_compatible(
error=str(exc),
**common_log,
)
- raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
-
- token_usage = {
- "model_config_id": config.id,
- "model_config_name": config.name,
- "model_provider": config.provider,
- "model_name": config.model_name,
- "source_module": log_module,
- "source_step_code": log_step,
- "input_tokens": input_tokens,
- "output_tokens": output_tokens,
- "total_tokens": total_tokens,
- "usage_reported": usage_reported,
- }
+ raise LlmProviderPostprocessError(f"模型响应解析失败: {exc}", usage=token_usage) from exc
return content, token_usage
diff --git a/video-gen-api/app/services/llm_billing/__init__.py b/video-gen-api/app/services/llm_billing/__init__.py
index 5e49ee5d..9dcbb82c 100644
--- a/video-gen-api/app/services/llm_billing/__init__.py
+++ b/video-gen-api/app/services/llm_billing/__init__.py
@@ -3,22 +3,24 @@ from app.services.llm_billing.context import (
LlmBillingContext,
LlmBillingPolicy,
LlmBillingStateError,
- LlmHoldResult,
- LlmHoldValidation,
+ LlmPreDeductResult,
+ LlmPreDeductValidation,
)
from app.services.llm_billing.service import (
- ensure_hold_exists,
+ ensure_pre_deducted,
get_llm_ledger_states,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
log_celery_dispatch_start,
log_celery_dispatch_success,
log_provider_failure,
+ record_provider_exception,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
- start_hold,
+ refund_on_final_failure,
+ finalize_llm_business_failure,
+ mark_business_success,
+ pre_deduct,
validate_retryable_previous_attempt,
)
@@ -27,19 +29,21 @@ __all__ = [
"LlmBillingContext",
"LlmBillingPolicy",
"LlmBillingStateError",
- "LlmHoldResult",
- "LlmHoldValidation",
- "start_hold",
- "ensure_hold_exists",
+ "LlmPreDeductResult",
+ "LlmPreDeductValidation",
+ "pre_deduct",
+ "ensure_pre_deducted",
"get_llm_ledger_states",
"validate_retryable_previous_attempt",
"log_provider_start",
"log_provider_success",
"log_provider_failure",
+ "record_provider_exception",
"log_celery_dispatch_start",
"log_celery_dispatch_success",
"log_celery_dispatch_failure",
"log_celery_dispatch_compensated",
- "settle_success",
- "release_on_failure",
+ "mark_business_success",
+ "refund_on_final_failure",
+ "finalize_llm_business_failure",
]
diff --git a/video-gen-api/app/services/llm_billing/call_audit_service.py b/video-gen-api/app/services/llm_billing/call_audit_service.py
new file mode 100644
index 00000000..331b14aa
--- /dev/null
+++ b/video-gen-api/app/services/llm_billing/call_audit_service.py
@@ -0,0 +1,271 @@
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+from sqlalchemy import func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.enums.llm_billing import LlmBillingExecutionStatus, LlmCallAttemptStatus
+from app.models.base import AsyncSessionLocal
+from app.models.credit_record import CreditRecord
+from app.models.llm_billing.call_attempt import LlmCallAttempt
+from app.models.llm_billing.execution import LlmBillingExecution
+from app.models.token_usage import TokenUsage
+from app.services.credit.utils import utc_now
+from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError
+from app.utils.id_gen import generate_id
+
+
+def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int:
+ for key in keys:
+ value = (usage or {}).get(key)
+ if value is not None:
+ try:
+ return max(0, int(value))
+ except (TypeError, ValueError):
+ pass
+ return 0
+
+
+async def create_call_attempt(
+ ctx: LlmBillingContext,
+ *,
+ detail: Mapping[str, Any] | None = None,
+) -> str:
+ async with AsyncSessionLocal() as db:
+ async with db.begin():
+ result = await db.execute(
+ select(LlmBillingExecution)
+ .where(LlmBillingExecution.id == ctx.billing_execution_id)
+ .limit(1)
+ .with_for_update()
+ )
+ execution = result.scalar_one_or_none()
+ if execution is None:
+ raise LlmBillingStateError("LLM固定预扣执行记录不存在,禁止调用模型")
+ if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id:
+ raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换")
+ if execution.status not in {
+ LlmBillingExecutionStatus.PRE_DEDUCTED.value,
+ LlmBillingExecutionStatus.PROCESSING.value,
+ }:
+ raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}")
+ max_result = await db.execute(
+ select(func.coalesce(func.max(LlmCallAttempt.call_sequence), 0)).where(
+ LlmCallAttempt.billing_execution_id == execution.id
+ )
+ )
+ sequence = int(max_result.scalar_one() or 0) + 1
+ attempt = LlmCallAttempt(
+ id=generate_id(),
+ billing_execution_id=execution.id,
+ call_sequence=sequence,
+ retry_sequence=max(0, sequence - 1),
+ model_config_id=execution.model_config_id or ctx.model_config_id,
+ model_name_snapshot=execution.model_name_snapshot or ctx.model_name,
+ provider_snapshot=execution.provider_snapshot or ctx.provider,
+ request_started_at=utc_now(),
+ status=LlmCallAttemptStatus.STARTED.value,
+ postprocess_status="pending",
+ )
+ db.add(attempt)
+ execution.status = LlmBillingExecutionStatus.PROCESSING.value
+ execution.total_call_count += 1
+ ctx.current_call_attempt_id = attempt.id
+ # 同一个业务 execution 可能有多次供应商调用;每个新调用必须清空上一调用的
+ # 成功事实和用量快照,避免下一次真实 provider failure 被误判为后处理失败。
+ ctx.provider_call_succeeded = False
+ ctx.provider_usage_snapshot = None
+ ctx.token_usage_id = None
+ return attempt.id
+
+
+async def _get_or_create_token_usage(
+ db: AsyncSession,
+ *,
+ ctx: LlmBillingContext,
+ attempt: LlmCallAttempt,
+ usage: Mapping[str, Any] | None,
+ input_tokens: int,
+ output_tokens: int,
+ total_tokens: int,
+) -> TokenUsage:
+ """先持久化 TokenUsage,再允许任何外键引用它。
+
+ LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy
+ 无法仅凭字符串赋值推导 INSERT/UPDATE 顺序,所以必须显式 flush TokenUsage。
+ 同时按 user_id + biz_key 复用记录,保证审计重试幂等。
+ """
+ biz_key = f"llm-call:{attempt.id}"
+ existing_result = await db.execute(
+ select(TokenUsage)
+ .where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
+ .limit(1)
+ )
+ existing = existing_result.scalar_one_or_none()
+ if existing is not None:
+ return existing
+
+ usage_row = TokenUsage(
+ id=generate_id(),
+ model_config_id=(usage or {}).get("model_config_id") or attempt.model_config_id,
+ user_id=ctx.user_id,
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ total_tokens=total_tokens,
+ owner_type=ctx.owner_type,
+ owner_id=ctx.owner_id,
+ biz_key=biz_key,
+ source_module=ctx.source_module,
+ source_step_code=ctx.source_step_code,
+ )
+ try:
+ async with db.begin_nested():
+ db.add(usage_row)
+ # 关键:先确保 token_usage 已 INSERT,后续 attempt.token_usage_id UPDATE
+ # 才不会违反 llm_call_attempts_token_usage_id_fkey。
+ await db.flush([usage_row])
+ except IntegrityError:
+ # 并发或审计重试可能已经创建同一 biz_key,回查复用。
+ existing_result = await db.execute(
+ select(TokenUsage)
+ .where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
+ .limit(1)
+ )
+ existing = existing_result.scalar_one_or_none()
+ if existing is None:
+ raise
+ return existing
+ return usage_row
+
+
+async def finish_call_success(
+ ctx: LlmBillingContext,
+ *,
+ usage: Mapping[str, Any] | None = None,
+) -> None:
+ if not ctx.current_call_attempt_id:
+ await create_call_attempt(ctx)
+ now = utc_now()
+ input_tokens = _usage_int(usage, "input_tokens", "prompt_tokens")
+ output_tokens = _usage_int(usage, "output_tokens", "completion_tokens")
+ total_tokens = _usage_int(usage, "total_tokens") or input_tokens + output_tokens
+ token_usage_id: str | None = None
+ async with AsyncSessionLocal() as db:
+ async with db.begin():
+ result = await db.execute(
+ select(LlmCallAttempt)
+ .where(LlmCallAttempt.id == ctx.current_call_attempt_id)
+ .limit(1)
+ .with_for_update()
+ )
+ attempt = result.scalar_one_or_none()
+ if attempt is None:
+ raise LlmBillingStateError("LLM调用审计记录不存在")
+ if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
+ ctx.token_usage_id = attempt.token_usage_id
+ return
+ previous_status = attempt.status
+ usage_row = await _get_or_create_token_usage(
+ db,
+ ctx=ctx,
+ attempt=attempt,
+ usage=usage,
+ input_tokens=input_tokens,
+ output_tokens=output_tokens,
+ total_tokens=total_tokens,
+ )
+ token_usage_id = usage_row.id
+
+ attempt.status = LlmCallAttemptStatus.SUCCEEDED.value
+ attempt.response_received_at = now
+ attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
+ attempt.input_tokens = input_tokens
+ attempt.output_tokens = output_tokens
+ attempt.total_tokens = total_tokens
+ attempt.token_usage_id = token_usage_id
+ attempt.provider_request_id = (usage or {}).get("provider_request_id") or (usage or {}).get("request_id")
+ try:
+ attempt.http_status = int((usage or {}).get("http_status")) if (usage or {}).get("http_status") is not None else None
+ except (TypeError, ValueError):
+ attempt.http_status = None
+ # 支持把此前因审计异常误记的 failed 调用恢复为真实 succeeded。
+ attempt.error_message = None
+ attempt.token_unavailable_reason = None
+
+ execution_result = await db.execute(
+ select(LlmBillingExecution)
+ .where(LlmBillingExecution.id == attempt.billing_execution_id)
+ .limit(1)
+ .with_for_update()
+ )
+ execution = execution_result.scalar_one()
+ if previous_status in {LlmCallAttemptStatus.FAILED.value, LlmCallAttemptStatus.TIMEOUT.value}:
+ execution.failed_call_count = max(0, int(execution.failed_call_count or 0) - 1)
+ execution.successful_call_count += 1
+ execution.total_input_tokens += input_tokens
+ execution.total_output_tokens += output_tokens
+ execution.total_tokens += total_tokens
+
+ record_result = await db.execute(
+ select(CreditRecord)
+ .where(CreditRecord.id == execution.credit_record_id)
+ .limit(1)
+ .with_for_update()
+ )
+ record = record_result.scalar_one_or_none()
+ if record:
+ record.input_tokens = execution.total_input_tokens
+ record.output_tokens = execution.total_output_tokens
+ record.total_tokens = execution.total_tokens
+ record.llm_call_count = execution.total_call_count
+ record.llm_success_call_count = execution.successful_call_count
+ record.llm_failed_call_count = execution.failed_call_count
+ record.token_usage_id = token_usage_id
+ ctx.token_usage_id = token_usage_id
+
+
+async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str:
+ if not ctx.current_call_attempt_id:
+ await create_call_attempt(ctx)
+ now = utc_now()
+ async with AsyncSessionLocal() as db:
+ async with db.begin():
+ result = await db.execute(
+ select(LlmCallAttempt)
+ .where(LlmCallAttempt.id == ctx.current_call_attempt_id)
+ .limit(1)
+ .with_for_update()
+ )
+ attempt = result.scalar_one_or_none()
+ if attempt is None:
+ return "missing"
+ if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
+ # 供应商已成功并已记录 Token;后续解析/校验/落库失败只能记为后处理失败,
+ # 不能覆盖真实供应商成功事实,也不能重复累计失败调用次数。
+ attempt.postprocess_status = "failed"
+ attempt.postprocess_error = str(error)[:1000]
+ return "postprocess_failure"
+ if attempt.status != LlmCallAttemptStatus.STARTED.value:
+ return "noop"
+ attempt.status = LlmCallAttemptStatus.TIMEOUT.value if status == "timeout" else LlmCallAttemptStatus.FAILED.value
+ attempt.response_received_at = now
+ attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
+ attempt.error_message = str(error)[:1000]
+ attempt.token_unavailable_reason = "供应商调用失败,未返回Token使用量"
+ execution_result = await db.execute(
+ select(LlmBillingExecution)
+ .where(LlmBillingExecution.id == attempt.billing_execution_id)
+ .limit(1)
+ .with_for_update()
+ )
+ execution = execution_result.scalar_one()
+ execution.failed_call_count += 1
+ record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update())
+ record = record_result.scalar_one_or_none()
+ if record:
+ record.llm_call_count = execution.total_call_count
+ record.llm_success_call_count = execution.successful_call_count
+ record.llm_failed_call_count = execution.failed_call_count
+ return "provider_failure"
diff --git a/video-gen-api/app/services/llm_billing/config.py b/video-gen-api/app/services/llm_billing/config.py
index 0426060f..e1e2ca12 100644
--- a/video-gen-api/app/services/llm_billing/config.py
+++ b/video-gen-api/app/services/llm_billing/config.py
@@ -1,145 +1,52 @@
from __future__ import annotations
+from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.enums.llm_billing import LlmBillingConfigKey
+from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.services.llm_billing.context import LlmBillingPolicy
-from app.services.system_config_cache import get_system_config_values
-
-_DEFAULT_HOLD_CREDITS = 5.0
-_FALSE_VALUES = {"0", "false", "no", "off", "disabled"}
-_HOLD_CONFIG_KEYS = {
- LlmBillingConfigKey.HOLD_DEFAULT.value,
- LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
- LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
- LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
-}
-
-
-def _parse_bool(value: str | None, *, default: bool = True) -> bool:
- if value is None or str(value).strip() == "":
- return default
- return str(value).strip().lower() not in _FALSE_VALUES
-
-
-def _parse_float(value: str | float | int | None) -> float | None:
- try:
- if value is None or str(value).strip() == "":
- return None
- return round(float(value), 2)
- except (TypeError, ValueError):
- return None
-
-
-def is_llm_hold_config_key(key: str | None) -> bool:
- return bool(key and key in _HOLD_CONFIG_KEYS)
async def get_llm_billing_policy(
db: AsyncSession,
*,
- config_key: str | None = None,
- explicit_hold_credits: float | None = None,
- default: float = _DEFAULT_HOLD_CREDITS,
+ scene_code: str | None = None,
) -> LlmBillingPolicy:
- keys = [LlmBillingConfigKey.ENABLED.value]
- if config_key:
- keys.append(config_key)
- keys.extend(
- [
- LlmBillingConfigKey.HOLD_DEFAULT.value,
- LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
- ]
- )
- # 去重并保持优先级;一次读取避免 enabled/scene/default 分散查询。
- ordered_keys = list(dict.fromkeys(keys))
- values = await get_system_config_values(db, ordered_keys)
- enabled = _parse_bool(values.get(LlmBillingConfigKey.ENABLED.value), default=True)
- if not enabled:
- return LlmBillingPolicy(enabled=False, hold_credits=0.0, config_key=config_key)
-
- if explicit_hold_credits is not None:
- amount = _parse_float(explicit_hold_credits)
- source_key = "explicit"
- else:
- amount = None
- source_key = None
- for key in ordered_keys[1:]:
- parsed = _parse_float(values.get(key))
- if parsed is not None:
- amount = parsed
- source_key = key
- break
- if amount is None:
- amount = round(float(default), 2)
- source_key = "default"
-
- if amount is None or amount <= 0:
+ """按业务场景读取固定预扣;不再读取旧 SystemConfig 冻结配置。"""
+ resolved_scene = scene_code
+ if not resolved_scene:
return LlmBillingPolicy(
- enabled=True,
- hold_credits=float(amount or 0),
- config_key=config_key,
- source_key=source_key,
+ pre_deduct_credits=0.0,
valid=False,
- error="启用LLM统一计费时,预扣积分必须大于0",
+ error="LLM业务场景不能为空",
+ )
+ result = await db.execute(
+ select(LlmBillingPolicyModel)
+ .where(LlmBillingPolicyModel.scene_code == resolved_scene)
+ .limit(1)
+ )
+ model = result.scalar_one_or_none()
+ if model is None:
+ return LlmBillingPolicy(
+ pre_deduct_credits=0.0,
+ valid=False,
+ error=f"未配置LLM场景固定预扣积分:{resolved_scene}",
+ )
+ amount = float(model.pre_deduct_credits)
+ if not model.is_active or amount <= 0:
+ return LlmBillingPolicy(
+ pre_deduct_credits=amount,
+ valid=False,
+ error=f"LLM场景固定预扣配置未启用或金额无效:{resolved_scene}",
+ policy_id=model.id,
+ version=model.version,
+ scene_name=model.scene_name,
)
return LlmBillingPolicy(
- enabled=True,
- hold_credits=round(float(amount), 2),
- config_key=config_key,
- source_key=source_key,
+ pre_deduct_credits=round(amount, 2),
+ valid=True,
+ policy_id=model.id,
+ version=model.version,
+ scene_name=model.scene_name,
)
-
-async def get_llm_hold_credits(
- db: AsyncSession,
- *,
- config_key: str | None = None,
- default: float = _DEFAULT_HOLD_CREDITS,
-) -> float:
- policy = await get_llm_billing_policy(db, config_key=config_key, default=default)
- return policy.hold_credits
-
-
-async def is_llm_billing_enabled(db: AsyncSession) -> bool:
- return (await get_llm_billing_policy(db)).enabled
-
-
-async def validate_llm_system_config_value(
- db: AsyncSession,
- *,
- key: str,
- value: str,
-) -> None:
- """校验后台单项更新,避免启用计费时保存零或负数预扣。"""
- if key == LlmBillingConfigKey.ENABLED.value:
- if not _parse_bool(value, default=True):
- return
- keys = [
- LlmBillingConfigKey.HOLD_DEFAULT.value,
- LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
- LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
- LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- ]
- values = await get_system_config_values(db, keys, ttl_seconds=1)
- invalid = [
- config_name
- for config_name in keys
- if (raw_value := values.get(config_name)) is not None
- and str(raw_value).strip() != ""
- and ((parsed := _parse_float(raw_value)) is None or parsed <= 0)
- ]
- if invalid:
- raise ValueError(f"启用LLM统一计费前,请先将以下预扣配置设置为大于0:{', '.join(invalid)}")
- return
-
- if not is_llm_hold_config_key(key):
- return
- parsed = _parse_float(value)
- enabled_values = await get_system_config_values(db, [LlmBillingConfigKey.ENABLED.value], ttl_seconds=1)
- enabled = _parse_bool(enabled_values.get(LlmBillingConfigKey.ENABLED.value), default=True)
- if enabled and (parsed is None or parsed <= 0):
- raise ValueError("启用LLM统一计费时,预扣积分必须大于0")
diff --git a/video-gen-api/app/services/llm_billing/context.py b/video-gen-api/app/services/llm_billing/context.py
index d94f0952..2bcbf63e 100644
--- a/video-gen-api/app/services/llm_billing/context.py
+++ b/video-gen-api/app/services/llm_billing/context.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
+from datetime import datetime
from app.enums.credit_record import CreditRecordChargeKind
from app.enums.llm_billing import LlmBillingLedgerState
@@ -8,25 +9,29 @@ from app.services.generation.billing_service import build_credit_biz_key
class LlmBillingConfigurationError(RuntimeError):
- """LLM 统一账务配置无效,必须在调用模型前终止。"""
+ """LLM 场景固定预扣配置无效。"""
class LlmBillingStateError(RuntimeError):
- """当前 attempt 的账务流水状态不允许继续执行。"""
+ """业务 attempt 的固定预扣状态不允许继续执行。"""
+
+
+class LlmProviderPostprocessError(RuntimeError):
+ """供应商请求已成功并返回用量,但响应内容或业务结构处理失败。"""
+
+ def __init__(self, message: str, *, usage: dict | None = None) -> None:
+ super().__init__(message)
+ self.usage = dict(usage or {})
@dataclass(slots=True, frozen=True)
class LlmBillingPolicy:
- enabled: bool
- hold_credits: float
- config_key: str | None = None
- source_key: str | None = None
+ pre_deduct_credits: float
valid: bool = True
error: str | None = None
-
- @property
- def bypassed(self) -> bool:
- return not self.enabled
+ policy_id: str | None = None
+ version: int | None = None
+ scene_name: str | None = None
@dataclass(slots=True)
@@ -42,72 +47,63 @@ class LlmBillingContext:
source_step_id: str | None = None
source_step_code: str | None = None
related_id: str | None = None
- hold_credits: float | None = None
- hold_config_key: str | None = None
description_prefix: str = "LLM"
trace_id: str | None = None
request_id: str | None = None
celery_task_id: str | None = None
provider: str | None = None
model_name: str | None = None
+ model_config_id: str | None = None
+ model_parameters_snapshot: dict | None = None
token_usage_id: str | None = None
+ # 供应商响应成功事实先于审计落库设置。即使审计事务暂时失败,
+ # 后续异常处理也不能把真实供应商成功覆盖成 provider failed。
+ provider_call_succeeded: bool = False
+ provider_usage_snapshot: dict | None = None
+ request_time: datetime | None = None
+ billing_execution_id: str | None = None
+ current_call_attempt_id: str | None = None
@property
- def hold_biz_key(self) -> str:
+ def pre_deduct_biz_key(self) -> str:
return build_credit_biz_key(
owner_type=self.owner_type,
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
- action="hold",
+ action="pre_deduct",
)
@property
- def hold_release_biz_key(self) -> str:
+ def refund_biz_key(self) -> str:
return build_credit_biz_key(
owner_type=self.owner_type,
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
- action="hold_release",
+ action="refund",
)
@property
- def charge_biz_key(self) -> str:
- return build_credit_biz_key(
- owner_type=self.owner_type,
- owner_id=self.owner_id,
- attempt_no=self.attempt_no,
- charge_kind=self.charge_kind,
- action="charge",
- )
-
- @property
- def ledger_biz_keys(self) -> tuple[str, str, str]:
- return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key
+ def billing_biz_key(self) -> str:
+ return self.pre_deduct_biz_key
@dataclass(slots=True, frozen=True)
-class LlmHoldResult:
+class LlmPreDeductResult:
amount: float
state: LlmBillingLedgerState
created: bool = False
record_id: str | None = None
reason: str | None = None
-
- @property
- def bypassed(self) -> bool:
- return self.state == LlmBillingLedgerState.BILLING_BYPASSED
+ execution_id: str | None = None
@dataclass(slots=True, frozen=True)
-class LlmHoldValidation:
+class LlmPreDeductValidation:
can_execute: bool
amount: float
state: LlmBillingLedgerState
reason: str | None = None
- hold_record_id: str | None = None
-
- @property
- def bypassed(self) -> bool:
- return self.state == LlmBillingLedgerState.BILLING_BYPASSED
+ pre_deduct_record_id: str | None = None
+ execution_id: str | None = None
diff --git a/video-gen-api/app/services/llm_billing/query_service.py b/video-gen-api/app/services/llm_billing/query_service.py
new file mode 100644
index 00000000..cdfadddf
--- /dev/null
+++ b/video-gen-api/app/services/llm_billing/query_service.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.llm_billing.call_attempt import LlmCallAttempt
+from app.models.llm_billing.execution import LlmBillingExecution
+
+
+async def list_executions_with_calls(
+ db: AsyncSession,
+ *,
+ page: int = 1,
+ page_size: int = 20,
+ scene_code: str | None = None,
+ status: str | None = None,
+ user_id: str | None = None,
+) -> tuple[list[dict], int]:
+ filters = []
+ if scene_code:
+ filters.append(LlmBillingExecution.scene_code == scene_code)
+ if status:
+ filters.append(LlmBillingExecution.status == status)
+ if user_id:
+ filters.append(LlmBillingExecution.user_id == user_id)
+ total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters))
+ total = int(total_result.scalar_one() or 0)
+ result = await db.execute(
+ select(LlmBillingExecution)
+ .where(*filters)
+ .order_by(LlmBillingExecution.created_at.desc(), LlmBillingExecution.id.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ executions = list(result.scalars().all())
+ ids = [item.id for item in executions]
+ calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in ids}
+ if ids:
+ call_result = await db.execute(
+ select(LlmCallAttempt)
+ .where(LlmCallAttempt.billing_execution_id.in_(ids))
+ .order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence)
+ )
+ for call in call_result.scalars().all():
+ calls_by_execution.setdefault(call.billing_execution_id, []).append(call)
+ items = []
+ for execution in executions:
+ data = {
+ column.name: getattr(execution, column.name)
+ for column in LlmBillingExecution.__table__.columns
+ }
+ data["calls"] = calls_by_execution.get(execution.id, [])
+ items.append(data)
+ return items, total
diff --git a/video-gen-api/app/services/llm_billing/service.py b/video-gen-api/app/services/llm_billing/service.py
index 21860a2f..2d24a88c 100644
--- a/video-gen-api/app/services/llm_billing/service.py
+++ b/video-gen-api/app/services/llm_billing/service.py
@@ -1,169 +1,377 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import Any, Iterable, Mapping
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_record import (
+ CREDIT_RECORD_BILLING_SCENE_LABELS,
CreditRecordAction,
- CreditRecordBillingScene,
- CreditRecordChargeKind,
- CreditRecordOwnerType,
- CreditRecordSourceModule,
CreditRecordSubject,
)
-from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingLedgerState
-from app.models.credit_record import CreditRecord
-from app.models.module_generation_step import ModuleGenerationStep
-from app.models.token_usage import TokenUsage
-from app.services.credit_record_meta_service import (
- CreditRecordMeta,
- build_generation_record_prompt_meta,
- build_module_step_prompt_meta,
- build_shot_video_analysis_meta,
+from app.enums.llm_billing import (
+ LlmBillingDomain,
+ LlmBillingEvent,
+ LlmBillingExecutionStatus,
+ LlmBillingLedgerState,
)
-from app.services.credits import add_credits_result, calc_text_credits, deduct_credits_result
+from app.models.base import AsyncSessionLocal
+from app.models.credit_record import CreditRecord
+from app.models.llm_billing.call_attempt import LlmCallAttempt
+from app.models.llm_billing.execution import LlmBillingExecution
+from app.models.model_config import ModelConfig
+from app.services.credit.locking import acquire_user_credit_lock
+from app.services.credit.utils import to_float, utc_now
+from app.services.credits import deduct_credits_result
from app.services.generation.billing_service import BillingItem, BillingSummary
+from app.services.llm_billing.call_audit_service import (
+ create_call_attempt,
+ finish_call_failure,
+ finish_call_success,
+)
from app.services.llm_billing.config import get_llm_billing_policy
from app.services.llm_billing.context import (
LlmBillingConfigurationError,
LlmBillingContext,
LlmBillingStateError,
- LlmHoldResult,
- LlmHoldValidation,
+ LlmProviderPostprocessError,
+ LlmPreDeductResult,
+ LlmPreDeductValidation,
)
from app.services.operation_log_service import log_operation_event
-from app.utils.exceptions import InsufficientCreditsError
from app.utils.id_gen import generate_id
-_LEDGER_QUERY_BATCH_SIZE = 1000
-
@dataclass(slots=True)
-class _LedgerRecords:
+class _ExecutionState:
+ execution: LlmBillingExecution | None
state: LlmBillingLedgerState
- hold: CreditRecord | None = None
- release: CreditRecord | None = None
- charge: CreditRecord | None = None
reason: str | None = None
- @property
- def hold_amount(self) -> float:
- return _round2(abs(float(self.hold.amount or 0))) if self.hold else 0.0
+
+def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str:
+ return (
+ policy_name
+ or CREDIT_RECORD_BILLING_SCENE_LABELS.get(ctx.billing_scene or "")
+ or ctx.description_prefix
+ or ctx.billing_scene
+ or "LLM功能"
+ )
-
-def _round2(value: Any) -> float:
- try:
- return round(float(value or 0), 2)
- except (TypeError, ValueError):
- return 0.0
-
-
-
-def _safe_int(value: Any, default: int = 0) -> int:
- try:
- if value is None or value == "":
- return default
- return int(value)
- except (TypeError, ValueError):
- return default
-
-
-
-def _context_detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]:
- detail = {
- "user_id": ctx.user_id,
+def _detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]:
+ return {
+ "scene_code": ctx.billing_scene,
"owner_type": ctx.owner_type,
"owner_id": ctx.owner_id,
"attempt_no": ctx.attempt_no,
- "charge_kind": ctx.charge_kind,
- "billing_scene": ctx.billing_scene,
- "source_module": ctx.source_module,
- "source_project_id": ctx.source_project_id,
- "source_step_id": ctx.source_step_id,
- "source_step_code": ctx.source_step_code,
- "related_id": ctx.related_id,
- "hold_biz_key": ctx.hold_biz_key,
- "hold_release_biz_key": ctx.hold_release_biz_key,
- "charge_biz_key": ctx.charge_biz_key,
- "celery_task_id": ctx.celery_task_id,
- "provider": ctx.provider,
+ "billing_execution_id": ctx.billing_execution_id,
+ "model_config_id": ctx.model_config_id,
"model_name": ctx.model_name,
- "token_usage_id": ctx.token_usage_id,
+ "provider": ctx.provider,
+ **extra,
}
- detail.update({key: value for key, value in extra.items() if value is not None})
- return {key: value for key, value in detail.items() if value is not None}
-
-def _log(
- ctx: LlmBillingContext,
- event: LlmBillingEvent,
- *,
- status: str = "success",
- message: str | None = None,
- detail: dict[str, Any] | None = None,
- error: str | None = None,
-) -> None:
+def _log(ctx: LlmBillingContext, event: LlmBillingEvent, *, status: str = "success", error: str | None = None, **detail: Any) -> None:
log_operation_event(
domain=LlmBillingDomain.LLM_BILLING.value,
- module=ctx.source_module or LlmBillingDomain.LLM_BILLING.value,
+ module=ctx.source_module or "llm",
event_type=event.value,
event_status=status,
- source="app.services.llm_billing.service",
trace_id=ctx.trace_id,
request_id=ctx.request_id,
user_id=ctx.user_id,
project_id=ctx.source_project_id,
- task_id=ctx.owner_id,
+ task_id=ctx.celery_task_id,
step_id=ctx.source_step_id,
- message=message,
- detail=detail or _context_detail(ctx),
+ message=_scene_name(ctx),
+ detail=_detail(ctx, **detail),
error=error,
)
+async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None:
+ if ctx.model_config_id:
+ result = await db.execute(select(ModelConfig).where(ModelConfig.id == ctx.model_config_id).limit(1))
+ model = result.scalar_one_or_none()
+ else:
+ result = await db.execute(
+ select(ModelConfig)
+ .where(ModelConfig.is_active.is_(True), ModelConfig.deleted_at.is_(None), ModelConfig.provider != "mock")
+ .order_by(ModelConfig.priority.desc(), ModelConfig.id.asc())
+ .limit(1)
+ )
+ model = result.scalar_one_or_none()
+ if model is None:
+ raise LlmBillingConfigurationError("没有可用的真实LLM模型,且本版本禁止Mock或自动降级")
+ ctx.model_config_id = model.id
+ ctx.model_name = model.model_name
+ ctx.provider = model.provider
+ ctx.model_parameters_snapshot = {
+ "model_config_id": model.id,
+ "name": model.name,
+ "provider": model.provider,
+ "api_base": model.api_base,
+ "model_name": model.model_name,
+ "max_tokens": model.max_tokens,
+ "temperature": model.temperature,
+ }
-def log_provider_start(ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
- _log(
- ctx,
- LlmBillingEvent.PROVIDER_START,
- status="started",
- detail=_context_detail(ctx, **dict(detail or {})),
+
+async def _find_execution(db: AsyncSession, ctx: LlmBillingContext, *, for_update: bool = False) -> LlmBillingExecution | None:
+ stmt = select(LlmBillingExecution).where(
+ LlmBillingExecution.user_id == ctx.user_id,
+ LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
+ LlmBillingExecution.owner_type == ctx.owner_type,
+ LlmBillingExecution.owner_id == ctx.owner_id,
+ LlmBillingExecution.business_attempt_no == ctx.attempt_no,
+ ).limit(1)
+ if for_update:
+ stmt = stmt.with_for_update()
+ result = await db.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLedgerState:
+ if execution is None:
+ return LlmBillingLedgerState.MISSING
+ mapping = {
+ LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE,
+ LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE,
+ LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED,
+ LlmBillingExecutionStatus.FINAL_FAILED.value: LlmBillingLedgerState.FINAL_FAILED,
+ LlmBillingExecutionStatus.REFUNDED.value: LlmBillingLedgerState.REFUNDED,
+ LlmBillingExecutionStatus.REFUND_FAILED.value: LlmBillingLedgerState.INVALID,
+ }
+ return mapping.get(execution.status, LlmBillingLedgerState.INVALID)
+
+
+async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductResult:
+ """在业务 API 事务内同步足额预扣固定场景积分。"""
+ _log(ctx, LlmBillingEvent.PRE_DEDUCT_START, status="started")
+ if not ctx.billing_scene:
+ raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene")
+ # 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求
+ # 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。
+ await acquire_user_credit_lock(db, ctx.user_id)
+ existing = await _find_execution(db, ctx, for_update=True)
+ if existing:
+ ctx.billing_execution_id = existing.id
+ ctx.model_config_id = existing.model_config_id
+ ctx.model_name = existing.model_name_snapshot
+ ctx.provider = existing.provider_snapshot
+ return LlmPreDeductResult(
+ amount=to_float(existing.pre_deduct_credits),
+ state=_state_for_execution(existing),
+ created=False,
+ record_id=existing.credit_record_id,
+ execution_id=existing.id,
+ )
+
+ await _select_fixed_model(db, ctx)
+ policy = await get_llm_billing_policy(
+ db,
+ scene_code=ctx.billing_scene,
+ )
+ if not policy.valid:
+ _log(ctx, LlmBillingEvent.PRE_DEDUCT_CONFIG_INVALID, status="failed", error=policy.error)
+ raise LlmBillingConfigurationError(policy.error or "LLM固定预扣配置无效")
+
+ scene_name = _scene_name(ctx, policy.scene_name)
+ meta = {
+ "owner_type": ctx.owner_type,
+ "owner_id": ctx.owner_id,
+ "attempt_no": ctx.attempt_no,
+ "charge_kind": ctx.charge_kind,
+ "charge_action": CreditRecordAction.PRE_DEDUCT.value,
+ "credit_subject": CreditRecordSubject.TEXT.value,
+ "billing_scene": ctx.billing_scene,
+ "scene_name_snapshot": scene_name,
+ "source_module": ctx.source_module,
+ "source_project_id": ctx.source_project_id,
+ "source_step_id": ctx.source_step_id,
+ "source_step_code": ctx.source_step_code,
+ "engine_type": "model",
+ "engine_id": ctx.model_config_id,
+ "engine_provider": ctx.provider,
+ "engine_model_name": ctx.model_name,
+ }
+ mutation = await deduct_credits_result(
+ db,
+ user_id=ctx.user_id,
+ amount=policy.pre_deduct_credits,
+ description=f"{scene_name}固定预扣积分",
+ related_id=ctx.related_id or ctx.owner_id,
+ biz_key=ctx.pre_deduct_biz_key,
+ record_meta=meta,
+ record_type="consume",
+ request_time=ctx.request_time or utc_now(),
+ )
+ if mutation.record is None:
+ raise LlmBillingStateError("LLM固定预扣流水创建失败")
+ execution = LlmBillingExecution(
+ id=generate_id(),
+ user_id=ctx.user_id,
+ scene_code=ctx.billing_scene,
+ scene_name_snapshot=scene_name,
+ owner_type=ctx.owner_type,
+ owner_id=ctx.owner_id,
+ business_attempt_no=ctx.attempt_no,
+ model_config_id=ctx.model_config_id,
+ model_name_snapshot=ctx.model_name,
+ provider_snapshot=ctx.provider,
+ model_parameters_snapshot=ctx.model_parameters_snapshot,
+ billing_policy_id=policy.policy_id,
+ billing_policy_version=policy.version,
+ request_time=ctx.request_time or utc_now(),
+ pre_deduct_credits=policy.pre_deduct_credits,
+ credit_record_id=mutation.record.id,
+ status=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
+ )
+ db.add(execution)
+ await db.flush()
+ mutation.record.llm_billing_execution_id = execution.id
+ mutation.record.scene_name_snapshot = scene_name
+ ctx.billing_execution_id = execution.id
+ _log(ctx, LlmBillingEvent.PRE_DEDUCT_SUCCESS, amount=policy.pre_deduct_credits, credit_record_id=mutation.record.id)
+ return LlmPreDeductResult(
+ amount=policy.pre_deduct_credits,
+ state=LlmBillingLedgerState.ACTIVE,
+ created=mutation.created,
+ record_id=mutation.record.id,
+ execution_id=execution.id,
)
-def log_provider_success(ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None:
- usage_snapshot = dict(usage or {})
- ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
- ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
- ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
- _log(
- ctx,
- LlmBillingEvent.PROVIDER_SUCCESS,
- detail=_context_detail(
- ctx,
- input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
- output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
- total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
- ),
+async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation:
+ execution = await _find_execution(db, ctx)
+ if execution is None:
+ return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing")
+ ctx.billing_execution_id = execution.id
+ ctx.model_config_id = execution.model_config_id
+ ctx.model_name = execution.model_name_snapshot
+ ctx.provider = execution.provider_snapshot
+ ctx.model_parameters_snapshot = execution.model_parameters_snapshot
+ state = _state_for_execution(execution)
+ can_execute = state == LlmBillingLedgerState.ACTIVE
+ if not can_execute:
+ _log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status)
+ else:
+ _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status)
+ return LlmPreDeductValidation(
+ can_execute=can_execute,
+ amount=to_float(execution.pre_deduct_credits),
+ state=state,
+ reason=None if can_execute else execution.status,
+ pre_deduct_record_id=execution.credit_record_id,
+ execution_id=execution.id,
)
-def log_provider_failure(ctx: LlmBillingContext, *, error: str) -> None:
+async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]:
+ items = list(contexts)
+ if not items:
+ return {}
+ conditions = [
+ and_(
+ LlmBillingExecution.user_id == ctx.user_id,
+ LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
+ LlmBillingExecution.owner_type == ctx.owner_type,
+ LlmBillingExecution.owner_id == ctx.owner_id,
+ LlmBillingExecution.business_attempt_no == ctx.attempt_no,
+ )
+ for ctx in items
+ ]
+ result = await db.execute(select(LlmBillingExecution).where(or_(*conditions)))
+ by_key = {
+ (row.user_id, row.scene_code, row.owner_type, row.owner_id, row.business_attempt_no): row
+ for row in result.scalars().all()
+ }
+ output: dict[str, LlmPreDeductValidation] = {}
+ for ctx in items:
+ row = by_key.get((ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no))
+ state = _state_for_execution(row)
+ output[ctx.pre_deduct_biz_key] = LlmPreDeductValidation(
+ can_execute=state == LlmBillingLedgerState.ACTIVE,
+ amount=to_float(row.pre_deduct_credits) if row else 0.0,
+ state=state,
+ reason=None if state == LlmBillingLedgerState.ACTIVE else (row.status if row else "missing"),
+ pre_deduct_record_id=row.credit_record_id if row else None,
+ execution_id=row.id if row else None,
+ )
+ return output
+
+
+async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation:
+ previous = replace(ctx, attempt_no=max(1, ctx.attempt_no - 1)) if ctx.attempt_no > 1 else ctx
+ validation = (await get_llm_ledger_states(db, [previous]))[previous.pre_deduct_biz_key]
+ if ctx.attempt_no <= 1:
+ return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt")
+ allowed = validation.state in {
+ LlmBillingLedgerState.SUCCEEDED,
+ LlmBillingLedgerState.REFUNDED,
+ LlmBillingLedgerState.FINAL_FAILED,
+ }
+ return LlmPreDeductValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.pre_deduct_record_id, validation.execution_id)
+
+
+async def log_provider_start(db: AsyncSession, ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
+ if not ctx.billing_execution_id:
+ validation = await ensure_pre_deducted(db, ctx)
+ if not validation.can_execute:
+ raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM")
+ await create_call_attempt(ctx, detail=detail)
+ _log(ctx, LlmBillingEvent.PROVIDER_START, status="started", call_attempt_id=ctx.current_call_attempt_id, detail=dict(detail or {}))
+
+
+async def log_provider_success(db: AsyncSession, ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None:
+ # 先记录内存中的供应商成功事实。即使独立审计事务异常,外层异常处理也
+ # 必须按“供应商成功、后处理失败”处理,不能覆盖成 provider failed。
+ ctx.provider_call_succeeded = True
+ ctx.provider_usage_snapshot = dict(usage or {})
+ await finish_call_success(ctx, usage=usage)
+ _log(ctx, LlmBillingEvent.PROVIDER_SUCCESS, call_attempt_id=ctx.current_call_attempt_id, usage=dict(usage or {}))
+
+
+async def log_provider_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str) -> None:
+ if ctx.provider_call_succeeded:
+ # 可能是首次成功审计落库失败后进入外层异常处理。先幂等补记真实成功,
+ # 再把当前异常记为 postprocess failure。
+ await finish_call_success(ctx, usage=ctx.provider_usage_snapshot or {})
+ outcome = await finish_call_failure(ctx, error=error)
+ event = LlmBillingEvent.POSTPROCESS_FAILURE if outcome == "postprocess_failure" else LlmBillingEvent.PROVIDER_FAILURE
_log(
ctx,
- LlmBillingEvent.PROVIDER_FAILURE,
+ event,
status="failed",
- detail=_context_detail(ctx, error_type="provider_call_failed"),
+ call_attempt_id=ctx.current_call_attempt_id,
+ failure_stage="postprocess" if outcome == "postprocess_failure" else "provider",
error=error,
)
+async def record_provider_exception(
+ db: AsyncSession,
+ ctx: LlmBillingContext,
+ exc: Exception,
+) -> tuple[bool, dict[str, Any]]:
+ """记录供应商失败,或“供应商成功但后处理失败”的真实调用事实。"""
+ if ctx.provider_call_succeeded:
+ usage = dict(ctx.provider_usage_snapshot or {})
+ await log_provider_failure(db, ctx, error=str(exc))
+ return True, usage
+ if isinstance(exc, LlmProviderPostprocessError):
+ usage = dict(exc.usage or {})
+ await log_provider_success(db, ctx, usage=usage)
+ await log_provider_failure(db, ctx, error=str(exc))
+ return True, usage
+ await log_provider_failure(db, ctx, error=str(exc))
+ return False, {}
+
+
def log_celery_dispatch_start(ctx: LlmBillingContext) -> None:
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_START, status="started")
@@ -177,976 +385,157 @@ def log_celery_dispatch_failure(ctx: LlmBillingContext, *, error: str) -> None:
def log_celery_dispatch_compensated(ctx: LlmBillingContext, *, error: str) -> None:
- _log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, detail=_context_detail(ctx, compensation_error=error))
+ _log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, status="warning", error=error)
-def _hold_meta(ctx: LlmBillingContext, *, action: str) -> CreditRecordMeta:
- subject = (
- CreditRecordSubject.ANALYSIS.value
- if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value
- else CreditRecordSubject.TEXT.value
- )
- return CreditRecordMeta(
- owner_type=ctx.owner_type,
- owner_id=ctx.owner_id,
- attempt_no=ctx.attempt_no,
- charge_kind=ctx.charge_kind,
- charge_action=action,
- credit_subject=subject,
- media_type="video" if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value else None,
- billing_scene=ctx.billing_scene,
- source_module=ctx.source_module,
- source_project_id=ctx.source_project_id,
- source_step_id=ctx.source_step_id,
- source_step_code=ctx.source_step_code,
- )
-
-
-
-def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) -> bool:
- if record is None:
- return True
- # 兼容旧数据:正式 biz_key 已明确动作、charge_action 为空时仍可识别。
- return record.charge_action in (None, expected.value)
-
-
-
-def _record_meta_matches_context(record: CreditRecord | None, ctx: LlmBillingContext) -> bool:
- if record is None:
- return True
- checks = (
- (record.owner_type, ctx.owner_type),
- (record.owner_id, ctx.owner_id),
- (record.attempt_no, ctx.attempt_no),
- (record.charge_kind, ctx.charge_kind),
- )
- return all(actual is None or str(actual) == str(expected) for actual, expected in checks)
-
-
-def _classify_ledger(
- *,
- ctx: LlmBillingContext,
- hold: CreditRecord | None,
- release: CreditRecord | None,
- charge: CreditRecord | None,
-) -> _LedgerRecords:
- if not _action_valid(hold, CreditRecordAction.HOLD):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_action_mismatch")
- if not _action_valid(release, CreditRecordAction.HOLD_RELEASE):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch")
- if not _action_valid(charge, CreditRecordAction.CHARGE):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch")
- if not _record_meta_matches_context(hold, ctx):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_context_mismatch")
- if not _record_meta_matches_context(release, ctx):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_context_mismatch")
- if not _record_meta_matches_context(charge, ctx):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_context_mismatch")
- if hold is None:
- if release is not None or charge is not None:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup")
- return _LedgerRecords(LlmBillingLedgerState.MISSING)
- if release is None and charge is None:
- if hold.type != "consume" or _round2(float(hold.amount or 0)) >= 0:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_type_or_sign_invalid")
- if _round2(abs(float(hold.amount or 0))) <= 0:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive")
- return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold)
- if release is not None and charge is None:
- if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
- if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
- if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
- return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release)
- if release is not None and charge is not None:
- if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
- if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
- if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
- if charge.type != "consume" or _round2(float(charge.amount or 0)) > 0:
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_type_or_sign_invalid")
- return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge)
- return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release")
-
-
-async def _load_ledgers(
- db: AsyncSession,
- contexts: Iterable[LlmBillingContext],
-) -> dict[str, _LedgerRecords]:
- context_list = list(contexts)
- if not context_list:
- return {}
- record_map: dict[tuple[str, str], CreditRecord] = {}
- all_keys = list(dict.fromkeys(key for ctx in context_list for key in ctx.ledger_biz_keys))
- user_ids = list(dict.fromkeys(ctx.user_id for ctx in context_list))
- for offset in range(0, len(all_keys), _LEDGER_QUERY_BATCH_SIZE):
- chunk = all_keys[offset : offset + _LEDGER_QUERY_BATCH_SIZE]
- result = await db.execute(
- select(CreditRecord).where(
- CreditRecord.user_id.in_(user_ids),
- CreditRecord.biz_key.in_(chunk),
- )
- )
- for record in result.scalars().all():
- if record.biz_key:
- record_map[(str(record.user_id), str(record.biz_key))] = record
-
- output: dict[str, _LedgerRecords] = {}
- for ctx in context_list:
- hold = record_map.get((ctx.user_id, ctx.hold_biz_key))
- release = record_map.get((ctx.user_id, ctx.hold_release_biz_key))
- charge = record_map.get((ctx.user_id, ctx.charge_biz_key))
- output[ctx.hold_biz_key] = _classify_ledger(ctx=ctx, hold=hold, release=release, charge=charge)
- return output
-
-
-async def _load_ledger(db: AsyncSession, ctx: LlmBillingContext) -> _LedgerRecords:
- return (await _load_ledgers(db, [ctx]))[ctx.hold_biz_key]
-
-
-async def get_llm_ledger_states(
- db: AsyncSession,
- contexts: Iterable[LlmBillingContext],
-) -> dict[str, LlmHoldValidation]:
- """批量读取 attempt 的三类流水;供恢复任务收集 ID 后统一过滤。"""
- context_list = list(contexts)
- ledgers = await _load_ledgers(db, context_list)
- return {
- ctx.hold_biz_key: LlmHoldValidation(
- can_execute=ledgers[ctx.hold_biz_key].state == LlmBillingLedgerState.ACTIVE,
- amount=ledgers[ctx.hold_biz_key].hold_amount,
- state=ledgers[ctx.hold_biz_key].state,
- reason=ledgers[ctx.hold_biz_key].reason,
- hold_record_id=ledgers[ctx.hold_biz_key].hold.id if ledgers[ctx.hold_biz_key].hold else None,
- )
- for ctx in context_list
- }
-
-
-async def validate_retryable_previous_attempt(
- db: AsyncSession,
- ctx: LlmBillingContext,
-) -> LlmHoldValidation:
- """校验失败 attempt 的账务是否已关闭,供业务创建下一 attempt 前调用。
-
- 只有已释放 HOLD,或当前计费明确关闭且旧 attempt 没有流水时,才允许创建
- 新 attempt。这里不主动退款,避免重试入口承担失败补偿职责。
- """
- _log(
- ctx,
- LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_START,
- status="started",
- )
- ledger = await _load_ledger(db, ctx)
-
- if ledger.state == LlmBillingLedgerState.RELEASED:
- result = LlmHoldValidation(
- True,
- ledger.hold_amount,
- ledger.state,
- hold_record_id=ledger.hold.id if ledger.hold else None,
- )
- _log(
- ctx,
- LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
- detail=_context_detail(
- ctx,
- ledger_state=result.state.value,
- hold_credits=result.amount,
- hold_record_id=result.hold_record_id,
- ),
- )
- return result
-
- if ledger.state == LlmBillingLedgerState.MISSING:
- policy = await get_llm_billing_policy(
- db,
- config_key=ctx.hold_config_key,
- explicit_hold_credits=None,
- )
- if policy.bypassed:
- result = LlmHoldValidation(
- True,
- 0.0,
- LlmBillingLedgerState.BILLING_BYPASSED,
- "billing_disabled",
- )
- _log(
- ctx,
- LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
- status="skipped",
- detail=_context_detail(
- ctx,
- ledger_state=result.state.value,
- billing_bypassed=True,
- ),
- )
- return result
- if not policy.valid:
- result = LlmHoldValidation(
- False,
- 0.0,
- LlmBillingLedgerState.INVALID,
- policy.error or "billing_config_invalid",
- )
- else:
- result = LlmHoldValidation(
- False,
- 0.0,
- ledger.state,
- "previous_attempt_hold_missing",
- )
- else:
- result = LlmHoldValidation(
- False,
- ledger.hold_amount,
- ledger.state,
- ledger.reason or f"previous_attempt_ledger_{ledger.state.value}",
- ledger.hold.id if ledger.hold else None,
- )
-
- _log(
- ctx,
- LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_BLOCKED,
- status="failed",
- detail=_context_detail(
- ctx,
- ledger_state=result.state.value,
- hold_credits=result.amount,
- hold_record_id=result.hold_record_id,
- skip_reason=result.reason,
- ),
- error="旧attempt账务尚未关闭,拒绝创建新的分析attempt",
- )
- return result
-
-
-async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult:
- # 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。
- ledger = await _load_ledger(db, ctx)
-
- # 配置可能在任务执行期间被关闭或修改:已经存在的 active HOLD 必须继续沿用,
- # 否则会留下永久冻结流水。只有“没有任何历史流水”的新 attempt 才允许按关闭配置绕过。
- if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
- amount = ledger.hold_amount
- ctx.hold_credits = amount
- _log(
- ctx,
- LlmBillingEvent.HOLD_SUCCESS,
- detail=_context_detail(
- ctx,
- hold_credits=amount,
- hold_record_id=ledger.hold.id,
- idempotent=True,
- ledger_state=ledger.state.value,
- ),
- )
- return LlmHoldResult(amount, ledger.state, created=False, record_id=ledger.hold.id)
-
- if ledger.state != LlmBillingLedgerState.MISSING:
- error = f"当前attempt账务状态为{ledger.state.value},不能复用旧预扣"
- _log(
- ctx,
- LlmBillingEvent.EXECUTION_BLOCKED,
- status="failed",
- detail=_context_detail(ctx, ledger_state=ledger.state.value, reason=ledger.reason),
- error=error,
- )
- raise LlmBillingStateError(error)
-
- policy = await get_llm_billing_policy(
- db,
- config_key=ctx.hold_config_key,
- explicit_hold_credits=ctx.hold_credits,
- )
- if policy.bypassed:
- _log(
- ctx,
- LlmBillingEvent.HOLD_BYPASSED,
- status="skipped",
- detail=_context_detail(ctx, ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value),
- )
- return LlmHoldResult(
- 0.0,
- LlmBillingLedgerState.BILLING_BYPASSED,
- reason="billing_disabled",
- )
- if not policy.valid:
- _log(
- ctx,
- LlmBillingEvent.HOLD_CONFIG_INVALID,
- status="failed",
- detail=_context_detail(
- ctx,
- hold_credits=policy.hold_credits,
- config_key=policy.config_key,
- config_source=policy.source_key,
- ),
- error=policy.error,
- )
- raise LlmBillingConfigurationError(policy.error or "LLM计费配置无效")
-
- amount = policy.hold_credits
- ctx.hold_credits = amount
- _log(
- ctx,
- LlmBillingEvent.HOLD_START,
- status="started",
- detail=_context_detail(
- ctx,
- hold_credits=amount,
- config_key=policy.config_key,
- config_source=policy.source_key,
- ),
- )
- try:
- mutation = await deduct_credits_result(
- db,
- user_id=ctx.user_id,
- amount=amount,
- description=f"{ctx.description_prefix}预扣积分",
- related_id=ctx.related_id or ctx.owner_id,
- biz_key=ctx.hold_biz_key,
- record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD.value),
- allow_negative=False,
- )
- except InsufficientCreditsError:
- _log(
- ctx,
- LlmBillingEvent.HOLD_INSUFFICIENT,
- status="failed",
- detail=_context_detail(ctx, hold_credits=amount),
- error="积分不足,无法预扣",
- )
- raise
-
- if not mutation.created:
- # 并发幂等命中后重新读取三类流水,避免复用已被另一事务释放的 HOLD。
- ledger = await _load_ledger(db, ctx)
- if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
- error = f"并发预扣后账务状态为{ledger.state.value},拒绝继续执行"
- _log(
- ctx,
- LlmBillingEvent.EXECUTION_BLOCKED,
- status="failed",
- detail=_context_detail(ctx, ledger_state=ledger.state.value),
- error=error,
- )
- raise LlmBillingStateError(error)
- mutation_record = ledger.hold
- amount = ledger.hold_amount
- else:
- mutation_record = mutation.record
-
- _log(
- ctx,
- LlmBillingEvent.HOLD_SUCCESS,
- detail=_context_detail(
- ctx,
- hold_credits=amount,
- hold_record_id=mutation_record.id if mutation_record else None,
- idempotent=not mutation.created,
- balance_before=mutation.balance_before,
- balance_after=mutation.balance_after,
- ledger_state=LlmBillingLedgerState.ACTIVE.value,
- ),
- )
- return LlmHoldResult(
- amount,
- LlmBillingLedgerState.ACTIVE,
- created=mutation.created,
- record_id=mutation_record.id if mutation_record else None,
- )
-
-
-async def ensure_hold_exists(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldValidation:
- """worker 调用模型前确认计费绕过或 active HOLD;不在 worker 首次预扣。"""
- _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started")
- ledger = await _load_ledger(db, ctx)
-
- # 先尊重已经落库的 attempt 账务状态,再处理当前配置。这样关闭计费不会
- # 把运行中的 active HOLD 遗留为永久冻结;而新建且没有 HOLD 的任务才会绕过。
- if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
- amount = ledger.hold_amount
- ctx.hold_credits = amount
- result = LlmHoldValidation(True, amount, ledger.state, hold_record_id=ledger.hold.id)
- _log(
- ctx,
- LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
- detail=_context_detail(
- ctx,
- hold_credits=amount,
- hold_record_id=ledger.hold.id,
- ledger_state=ledger.state.value,
- ),
- )
- return result
-
- if ledger.state == LlmBillingLedgerState.MISSING:
- policy = await get_llm_billing_policy(
- db,
- config_key=ctx.hold_config_key,
- explicit_hold_credits=None,
- )
- else:
- policy = None
- if policy is not None and policy.bypassed:
- result = LlmHoldValidation(
- True,
- 0.0,
- LlmBillingLedgerState.BILLING_BYPASSED,
- "billing_disabled",
- )
- _log(
- ctx,
- LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
- detail=_context_detail(
- ctx,
- ledger_state=result.state.value,
- billing_bypassed=True,
- ),
- )
- return result
-
- if policy is not None and not policy.valid:
- result = LlmHoldValidation(False, 0.0, LlmBillingLedgerState.INVALID, "billing_config_invalid")
- _log(
- ctx,
- LlmBillingEvent.EXECUTION_BLOCKED,
- status="failed",
- detail=_context_detail(ctx, ledger_state=result.state.value),
- error=policy.error,
- )
- return result
-
- result = LlmHoldValidation(
- False,
- ledger.hold_amount,
- ledger.state,
- ledger.reason or f"ledger_{ledger.state.value}",
- ledger.hold.id if ledger.hold else None,
- )
- event = (
- LlmBillingEvent.HOLD_MISSING
- if ledger.state == LlmBillingLedgerState.MISSING
- else LlmBillingEvent.EXECUTION_BLOCKED
- )
- _log(
- ctx,
- event,
- status="failed",
- detail=_context_detail(
- ctx,
- hold_credits=result.amount,
- hold_record_id=result.hold_record_id,
- ledger_state=result.state.value,
- skip_reason=result.reason,
- ),
- error="LLM预扣不是有效冻结状态,拒绝调用模型",
- )
- return result
-
-
-async def _release_active_hold(
+async def mark_business_success(
db: AsyncSession,
ctx: LlmBillingContext,
*,
- hold_record: CreditRecord,
- reason: str,
-) -> BillingItem:
- amount = _round2(abs(float(hold_record.amount or 0)))
- ctx.hold_credits = amount
- if amount <= 0:
- _log(ctx, LlmBillingEvent.HOLD_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_record_id=hold_record.id, reason=reason, skip_reason="hold_amount_not_positive"))
- return BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=0.0,
- charged=False,
- skipped_reason="hold_amount_not_positive",
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
- )
-
- _log(ctx, LlmBillingEvent.HOLD_RELEASE_START, status="started", detail=_context_detail(ctx, hold_credits=amount, hold_record_id=hold_record.id, reason=reason))
- mutation = await add_credits_result(
- db,
- user_id=ctx.user_id,
- amount=amount,
- description=f"{ctx.description_prefix}预扣积分释放",
- related_id=ctx.related_id or ctx.owner_id,
- record_type="refund",
- biz_key=ctx.hold_release_biz_key,
- refund_for_biz_key=ctx.hold_biz_key,
- record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD_RELEASE.value),
- )
- _log(
- ctx,
- LlmBillingEvent.HOLD_RELEASE_SUCCESS,
- detail=_context_detail(
- ctx,
- hold_credits=amount,
- hold_record_id=hold_record.id,
- hold_release_record_id=mutation.record.id if mutation.record else None,
- reason=reason,
- idempotent=not mutation.created,
- balance_before=mutation.balance_before,
- balance_after=mutation.balance_after,
- ),
- )
- return BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=amount,
- charged=False,
- skipped_reason=None if mutation.created else "already_released",
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
- )
-
-
-async def release_hold(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
- ledger = await _load_ledger(db, ctx)
-
- # 即使管理员已经关闭计费,历史 active HOLD 也必须按真实冻结流水释放。
- if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
- return await _release_active_hold(db, ctx, hold_record=ledger.hold, reason=reason)
- if ledger.state in (LlmBillingLedgerState.RELEASED, LlmBillingLedgerState.CHARGED):
- amount = _round2(abs(float(ledger.release.amount or 0))) if ledger.release else ledger.hold_amount
- _log(
- ctx,
- LlmBillingEvent.HOLD_RELEASE_SKIPPED,
- status="skipped",
- detail=_context_detail(
- ctx,
- reason=reason,
- hold_credits=amount,
- ledger_state=ledger.state.value,
- skip_reason="already_released",
- idempotent=True,
- ),
- )
- return BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=amount,
- charged=False,
- skipped_reason="already_released",
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
- )
- if ledger.state == LlmBillingLedgerState.MISSING:
- policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
- else:
- policy = None
- if policy is not None and policy.bypassed:
- _log(
- ctx,
- LlmBillingEvent.HOLD_RELEASE_SKIPPED,
- status="skipped",
- detail=_context_detail(
- ctx,
- reason=reason,
- ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
- skip_reason="billing_disabled",
- ),
- )
- return BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=0.0,
- charged=False,
- skipped_reason="billing_disabled",
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
- )
-
- _log(
- ctx,
- LlmBillingEvent.HOLD_RELEASE_SKIPPED,
- status="skipped",
- detail=_context_detail(
- ctx,
- reason=reason,
- ledger_state=ledger.state.value,
- skip_reason=ledger.reason or ledger.state.value,
- ),
- error="没有可释放的有效LLM预扣流水",
- )
- return BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=0.0,
- charged=False,
- skipped_reason=ledger.reason or ledger.state.value,
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
- )
-
-
-async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
- _log(ctx, LlmBillingEvent.FAILURE_RELEASE_START, status="started", detail=_context_detail(ctx, error=error), error=error)
- item = await release_hold(db, ctx, reason="failure")
- if item.amount > 0 and not item.skipped_reason:
- _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SUCCESS, detail=_context_detail(ctx, hold_credits=item.amount, error=error), error=error)
- else:
- _log(ctx, LlmBillingEvent.FAILURE_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_credits=item.amount, error=error, skip_reason=item.skipped_reason or "hold_not_active"), error=error)
- return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item])
-
-
-def _normalize_settlement_usage(
- ctx: LlmBillingContext,
usage: Mapping[str, Any],
-) -> dict[str, Any]:
- snapshot = dict(usage or {})
- try:
- if snapshot.get("usage_reported") is False and not bool(snapshot.get("billing_free")):
- raise ValueError("provider_usage_missing")
- if "input_tokens" not in snapshot or "output_tokens" not in snapshot:
- raise ValueError("input_or_output_tokens_missing")
- input_tokens = int(snapshot.get("input_tokens"))
- output_tokens = int(snapshot.get("output_tokens"))
- if input_tokens < 0 or output_tokens < 0:
- raise ValueError("token_count_negative")
- normalized_total = input_tokens + output_tokens
- raw_total = snapshot.get("total_tokens")
- if raw_total not in (None, ""):
- total_tokens = int(raw_total)
- if total_tokens < 0:
- raise ValueError("total_tokens_negative")
- else:
- total_tokens = normalized_total
- if total_tokens != normalized_total:
- snapshot["reported_total_tokens"] = total_tokens
- total_tokens = normalized_total
- snapshot["input_tokens"] = input_tokens
- snapshot["output_tokens"] = output_tokens
- snapshot["total_tokens"] = total_tokens
- return snapshot
- except (TypeError, ValueError) as exc:
- _log(
- ctx,
- LlmBillingEvent.USAGE_INVALID,
- status="failed",
- detail=_context_detail(ctx, usage=snapshot, reason=str(exc)),
- error="LLM usage 无效,拒绝释放 HOLD 和创建真实扣费",
- )
- raise LlmBillingStateError(f"LLM usage 无效:{exc}") from exc
-
-
-async def _ensure_token_usage_once(
- db: AsyncSession,
- ctx: LlmBillingContext,
- usage: dict[str, Any],
-) -> TokenUsage:
- supplied_id = str(usage.get("token_usage_id") or "").strip() or None
- if supplied_id:
- supplied_result = await db.execute(
- select(TokenUsage).where(TokenUsage.id == supplied_id).limit(1)
- )
- supplied = supplied_result.scalar_one_or_none()
- if supplied is not None:
- if supplied.user_id not in (None, ctx.user_id):
- raise LlmBillingStateError("TokenUsage 用户归属与当前账务上下文不一致")
- if supplied.biz_key not in (None, ctx.charge_biz_key):
- raise LlmBillingStateError("TokenUsage biz_key 与当前 charge 不一致")
- supplied.user_id = supplied.user_id or ctx.user_id
- supplied.owner_type = supplied.owner_type or ctx.owner_type
- supplied.owner_id = supplied.owner_id or ctx.owner_id
- supplied.biz_key = supplied.biz_key or ctx.charge_biz_key
- supplied.source_module = supplied.source_module or ctx.source_module
- supplied.source_step_code = supplied.source_step_code or ctx.source_step_code
- usage["token_usage_id"] = supplied.id
- ctx.token_usage_id = supplied.id
- _log(
- ctx,
- LlmBillingEvent.TOKEN_USAGE_REUSED,
- detail=_context_detail(ctx, token_usage_id=supplied.id, source="supplied_id"),
- )
- return supplied
-
- result = await db.execute(
- select(TokenUsage)
- .where(
- TokenUsage.user_id == ctx.user_id,
- TokenUsage.biz_key == ctx.charge_biz_key,
- )
+ description: str | None = None,
+) -> BillingSummary:
+ # 调用审计使用独立短事务,必须在当前 Session 锁定 execution 之前完成。
+ # 若先锁 execution 再等待独立 Session 更新同一行,会形成自锁死等待。
+ if ctx.current_call_attempt_id:
+ await finish_call_success(ctx, usage=usage)
+ execution = await _find_execution(db, ctx, for_update=True)
+ if execution is None:
+ raise LlmBillingStateError("LLM固定预扣执行记录不存在")
+ ctx.billing_execution_id = execution.id
+ created = execution.status != LlmBillingExecutionStatus.SUCCEEDED.value
+ if execution.status in {LlmBillingExecutionStatus.REFUNDED.value, LlmBillingExecutionStatus.FINAL_FAILED.value}:
+ raise LlmBillingStateError("LLM业务已最终失败并退款,不能再标记成功")
+ execution.status = LlmBillingExecutionStatus.SUCCEEDED.value
+ execution.completed_at = utc_now()
+ call_result = await db.execute(
+ select(LlmCallAttempt)
+ .where(LlmCallAttempt.billing_execution_id == execution.id)
+ .order_by(LlmCallAttempt.call_sequence.desc())
.limit(1)
)
- existing = result.scalar_one_or_none()
- if existing is not None:
- usage["token_usage_id"] = existing.id
- ctx.token_usage_id = existing.id
- _log(
- ctx,
- LlmBillingEvent.TOKEN_USAGE_REUSED,
- detail=_context_detail(ctx, token_usage_id=existing.id, source="biz_key"),
- )
- return existing
-
- token_usage = TokenUsage(
- id=generate_id(),
- model_config_id=usage.get("model_config_id"),
+ last_call = call_result.scalar_one_or_none()
+ if last_call:
+ last_call.postprocess_status = "succeeded"
+ last_call.postprocess_error = None
+ record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update())
+ record = record_result.scalar_one_or_none()
+ if record:
+ record.input_tokens = execution.total_input_tokens
+ record.output_tokens = execution.total_output_tokens
+ record.total_tokens = execution.total_tokens
+ record.llm_call_count = execution.total_call_count
+ record.llm_success_call_count = execution.successful_call_count
+ record.llm_failed_call_count = execution.failed_call_count
+ _log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, pre_deduct_credits=to_float(execution.pre_deduct_credits), idempotent=not created)
+ return BillingSummary(
+ record_id=ctx.owner_id,
user_id=ctx.user_id,
- input_tokens=int(usage["input_tokens"]),
- output_tokens=int(usage["output_tokens"]),
- total_tokens=int(usage["total_tokens"]),
- owner_type=ctx.owner_type,
- owner_id=ctx.owner_id,
- biz_key=ctx.charge_biz_key,
- source_module=ctx.source_module,
- source_step_code=ctx.source_step_code,
- )
- try:
- async with db.begin_nested():
- db.add(token_usage)
- await db.flush()
- except IntegrityError:
- if token_usage in db.sync_session:
- db.sync_session.expunge(token_usage)
- result = await db.execute(
- select(TokenUsage)
- .where(
- TokenUsage.user_id == ctx.user_id,
- TokenUsage.biz_key == ctx.charge_biz_key,
+ items=[
+ BillingItem(
+ charge_key=ctx.charge_kind,
+ amount=to_float(execution.pre_deduct_credits),
+ charged=created,
+ skipped_reason=None if created else "already_succeeded",
+ biz_key=ctx.billing_biz_key,
+ attempt_no=ctx.attempt_no,
)
- .limit(1)
- )
- token_usage = result.scalar_one_or_none()
- if token_usage is None:
- raise
- event = LlmBillingEvent.TOKEN_USAGE_REUSED
- else:
- event = LlmBillingEvent.TOKEN_USAGE_CREATED
-
- usage["token_usage_id"] = token_usage.id
- ctx.token_usage_id = token_usage.id
- _log(
- ctx,
- event,
- detail=_context_detail(
- ctx,
- token_usage_id=token_usage.id,
- input_tokens=token_usage.input_tokens,
- output_tokens=token_usage.output_tokens,
- total_tokens=token_usage.total_tokens,
- ),
- )
- return token_usage
-
-
-async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta:
- usage_snapshot = dict(usage or {})
- ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
- ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
- ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
- if ctx.owner_type == CreditRecordOwnerType.GENERATION_RECORD.value:
- return await build_generation_record_prompt_meta(
- db,
- record_id=ctx.owner_id,
- attempt_no=ctx.attempt_no,
- charge_kind=ctx.charge_kind,
- usage=usage_snapshot,
- )
- if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
- return await build_module_step_prompt_meta(
- db,
- step_id=ctx.owner_id,
- attempt_no=ctx.attempt_no,
- usage=usage_snapshot,
- )
- if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
- return await build_shot_video_analysis_meta(
- db,
- owner_type=ctx.owner_type,
- owner_id=ctx.owner_id,
- attempt_no=ctx.attempt_no,
- usage=usage_snapshot,
- billing_scene=ctx.billing_scene or CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value,
- source_project_id=ctx.source_project_id,
- source_step_id=ctx.source_step_id,
- )
- return CreditRecordMeta(
- owner_type=ctx.owner_type,
- owner_id=ctx.owner_id,
- attempt_no=ctx.attempt_no,
- charge_kind=ctx.charge_kind,
- charge_action=CreditRecordAction.CHARGE.value,
- credit_subject=CreditRecordSubject.TEXT.value,
- billing_scene=ctx.billing_scene,
- source_module=ctx.source_module,
- source_project_id=ctx.source_project_id,
- source_step_id=ctx.source_step_id,
- source_step_code=ctx.source_step_code,
- token_usage_id=usage_snapshot.get("token_usage_id"),
- input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
- output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
- total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
+ ],
)
-async def _settle_success_impl(
- db: AsyncSession,
- ctx: LlmBillingContext,
- *,
- usage: Mapping[str, Any],
- description: str | None = None,
-) -> BillingSummary:
- ledger = await _load_ledger(db, ctx)
+async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
+ from app.services.credit.ledger_service import refund_consumption
- if ledger.state == LlmBillingLedgerState.CHARGED and ledger.hold and ledger.release and ledger.charge:
- release_item = BillingItem(
- charge_key=CreditRecordAction.HOLD_RELEASE.value,
- amount=abs(_round2(ledger.release.amount)),
- charged=False,
- skipped_reason="already_released",
- biz_key=ctx.hold_release_biz_key,
- attempt_no=ctx.attempt_no,
+ # 与固定预扣保持 user advisory -> execution -> balance 的统一锁顺序。
+ await acquire_user_credit_lock(db, ctx.user_id)
+ execution = await _find_execution(db, ctx, for_update=True)
+ if execution is None:
+ return BillingSummary(ctx.owner_id, ctx.user_id, [])
+ ctx.billing_execution_id = execution.id
+ if execution.status == LlmBillingExecutionStatus.REFUNDED.value:
+ return BillingSummary(
+ ctx.owner_id,
+ ctx.user_id,
+ [BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)],
)
- charge_item = BillingItem(
- charge_key=ctx.charge_kind,
- amount=abs(_round2(ledger.charge.amount)),
- charged=False,
- skipped_reason="already_charged",
- biz_key=ctx.charge_biz_key,
- attempt_no=ctx.attempt_no,
- )
- _log(
- ctx,
- LlmBillingEvent.SETTLE_SUCCESS,
- detail=_context_detail(
- ctx,
- actual_credits=charge_item.amount,
- ledger_state=ledger.state.value,
- idempotent=True,
- ),
- )
- return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
-
- # 新任务在关闭计费时没有 HOLD,成功后直接绕过;历史 active HOLD 则必须继续结算。
- if ledger.state == LlmBillingLedgerState.MISSING:
- policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
- else:
- policy = None
- if policy is not None and policy.bypassed:
- _log(
- ctx,
- LlmBillingEvent.SETTLE_SUCCESS,
- status="skipped",
- detail=_context_detail(
- ctx,
- ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
- billing_bypassed=True,
- ),
- )
- return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[])
-
- if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
- # 统一由 settle_success 外层记录一次 SETTLE_FAILED,避免同一异常产生重复日志。
- raise LlmBillingStateError(
- f"当前attempt账务状态为{ledger.state.value},不能执行成功结算"
- )
-
- normalized_usage = _normalize_settlement_usage(ctx, usage)
- await _ensure_token_usage_once(db, ctx, normalized_usage)
- _log(
- ctx,
- LlmBillingEvent.SETTLE_START,
- status="started",
- detail=_context_detail(
- ctx,
- ledger_state=ledger.state.value,
- input_tokens=normalized_usage["input_tokens"],
- output_tokens=normalized_usage["output_tokens"],
- total_tokens=normalized_usage["total_tokens"],
- provider=normalized_usage.get("provider") or normalized_usage.get("model_provider"),
- model_name=normalized_usage.get("model_name") or normalized_usage.get("model"),
- ),
- )
- input_tokens = int(normalized_usage["input_tokens"])
- output_tokens = int(normalized_usage["output_tokens"])
- amount = await calc_text_credits(db, input_tokens, output_tokens)
- meta = await _build_charge_meta(db, ctx, normalized_usage)
- release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success")
- if meta.charge_action is None:
- meta.charge_action = CreditRecordAction.CHARGE.value
- meta.billing_scene = meta.billing_scene or ctx.billing_scene
- meta.source_module = meta.source_module or ctx.source_module
- meta.source_project_id = meta.source_project_id or ctx.source_project_id
- meta.source_step_id = meta.source_step_id or ctx.source_step_id
- meta.source_step_code = meta.source_step_code or ctx.source_step_code
-
- mutation = await deduct_credits_result(
- db,
- user_id=ctx.user_id,
- amount=amount,
- description=description or f"{ctx.description_prefix}真实扣费",
- related_id=ctx.related_id or ctx.owner_id,
- biz_key=ctx.charge_biz_key,
- record_meta=meta,
- allow_negative=True,
- create_zero_record=True,
- )
- charged_amount = mutation.amount
- charge_item = BillingItem(
- charge_key=ctx.charge_kind,
- amount=charged_amount,
- charged=mutation.created,
- skipped_reason=None if mutation.created else "already_charged",
- biz_key=ctx.charge_biz_key,
- attempt_no=ctx.attempt_no,
- )
-
- if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
- result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == ctx.owner_id).limit(1))
- step = result.scalar_one_or_none()
- if step:
- step.token_usage_id = meta.token_usage_id
- step.model_config_id = normalized_usage.get("model_config_id")
- step.input_tokens = meta.input_tokens
- step.output_tokens = meta.output_tokens
- step.total_tokens = meta.total_tokens
- step.text_credits_cost = charged_amount
-
- after_balance = mutation.balance_after
- _log(
- ctx,
- LlmBillingEvent.CHARGE_SUCCESS,
- detail=_context_detail(
- ctx,
- actual_credits=charged_amount,
- charge_record_id=mutation.record.id if mutation.record else None,
- idempotent=not mutation.created,
- balance_before=mutation.balance_before,
- balance_after=after_balance,
- allow_negative=True,
- ),
- )
- if after_balance < 0:
- _log(ctx, LlmBillingEvent.CHARGE_NEGATIVE_BALANCE, status="warning", detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, allow_negative=True))
- _log(ctx, LlmBillingEvent.SETTLE_SUCCESS, detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, ledger_state=LlmBillingLedgerState.CHARGED.value, idempotent=not mutation.created))
- return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
-
-async def settle_success(
- db: AsyncSession,
- ctx: LlmBillingContext,
- *,
- usage: Mapping[str, Any],
- description: str | None = None,
-) -> BillingSummary:
- """成功结算统一入口;任何异常都留下可检索的 SETTLE_FAILED 日志。"""
+ if execution.status == LlmBillingExecutionStatus.SUCCEEDED.value:
+ # 调用方只有在业务最终失败时才能进这里;允许从业务成功调用记录转最终失败退款。
+ pass
+ execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value
+ execution.final_error_message = str(error or "业务最终失败")[:1000]
+ execution.completed_at = utc_now()
+ _log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error)
try:
- return await _settle_success_impl(
+ refunded = await refund_consumption(
db,
+ user_id=ctx.user_id,
+ refund_for_biz_key=ctx.pre_deduct_biz_key,
+ description=f"{execution.scene_name_snapshot}最终失败",
+ related_id=ctx.related_id or ctx.owner_id,
+ biz_key=f"{ctx.pre_deduct_biz_key}:business-final-refund",
+ record_meta={
+ "owner_type": ctx.owner_type,
+ "owner_id": ctx.owner_id,
+ "attempt_no": ctx.attempt_no,
+ "charge_kind": ctx.charge_kind,
+ "charge_action": CreditRecordAction.REFUND.value,
+ "credit_subject": CreditRecordSubject.REFUND.value,
+ "billing_scene": ctx.billing_scene,
+ "scene_name_snapshot": execution.scene_name_snapshot,
+ "source_module": ctx.source_module,
+ "source_project_id": ctx.source_project_id,
+ "source_step_id": ctx.source_step_id,
+ "source_step_code": ctx.source_step_code,
+ "llm_billing_execution_id": execution.id,
+ },
+ refund_time=utc_now(),
+ )
+ execution.refund_available_credits = refunded.available_amount
+ execution.refund_expired_credits = refunded.expired_amount
+ execution.status = LlmBillingExecutionStatus.REFUNDED.value
+ execution.refunded_at = utc_now()
+ _log(
ctx,
- usage=usage,
- description=description,
+ LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS,
+ refund_available=to_float(refunded.available_amount),
+ refund_expired=to_float(refunded.expired_amount),
+ )
+ return BillingSummary(
+ ctx.owner_id,
+ ctx.user_id,
+ [BillingItem(ctx.charge_kind, to_float(refunded.total_amount), True, None, ctx.refund_biz_key, ctx.attempt_no)],
)
except Exception as exc:
- _log(
- ctx,
- LlmBillingEvent.SETTLE_FAILED,
- status="failed",
- detail=_context_detail(ctx, error_type=type(exc).__name__),
- error=str(exc),
- )
+ execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
+ _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc))
+ raise
+
+
+async def refund_pre_deduct(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
+ summary = await refund_on_final_failure(db, ctx, error=reason)
+ return summary.items[0] if summary.items else BillingItem(ctx.charge_kind, 0.0, False, "missing", ctx.refund_biz_key, ctx.attempt_no)
+
+
+async def finalize_llm_business_failure(
+ ctx: LlmBillingContext,
+ *,
+ error: str | None = None,
+) -> BillingSummary:
+ """在独立短事务中完成最终失败退款,并确保退款失败状态不会被业务 rollback 吞掉。"""
+ try:
+ async with AsyncSessionLocal() as session:
+ async with session.begin():
+ return await refund_on_final_failure(session, ctx, error=error)
+ except Exception as exc:
+ try:
+ async with AsyncSessionLocal() as audit_session:
+ async with audit_session.begin():
+ execution = await _find_execution(audit_session, ctx, for_update=True)
+ if execution is not None and execution.status != LlmBillingExecutionStatus.REFUNDED.value:
+ execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
+ execution.final_error_message = str(error or exc)[:1000]
+ execution.completed_at = utc_now()
+ except Exception:
+ _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), persist_failed_status=False)
raise
diff --git a/video-gen-api/app/services/module_async_recovery_service.py b/video-gen-api/app/services/module_async_recovery_service.py
index 6e351e50..e624c998 100644
--- a/video-gen-api/app/services/module_async_recovery_service.py
+++ b/video-gen-api/app/services/module_async_recovery_service.py
@@ -19,7 +19,7 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
-from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
+from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import (
ModuleCodeEnum as ShotModuleCodeEnum,
@@ -38,8 +38,7 @@ from app.services.redis_registry_service import (
utc_now,
)
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values
-from app.services.llm_billing import LlmBillingContext, LlmHoldValidation, get_llm_ledger_states
-from app.services.llm_billing.config import get_llm_billing_policy
+from app.services.llm_billing import LlmBillingContext, LlmPreDeductValidation, get_llm_ledger_states
from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen")
@@ -93,12 +92,7 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
- hold_config_key=(
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
- if is_image_prompt
- else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
- ),
- description_prefix="模块AI提词优化",
+ description_prefix="模块AI提词优化",
trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}",
)
@@ -106,35 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
async def _load_step_billing_validations(
db: AsyncSession,
steps: Iterable[ModuleGenerationStep],
-) -> dict[str, LlmHoldValidation]:
+) -> dict[str, LlmPreDeductValidation]:
step_list = list(steps)
if not step_list:
return {}
contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list}
- policies = {}
- for config_key in {ctx.hold_config_key for ctx in contexts.values() if ctx.hold_config_key}:
- policies[config_key] = await get_llm_billing_policy(db, config_key=config_key)
- # 无论当前配置是否关闭,都批量读取历史 attempt 流水:运行中的 active HOLD
- # 必须继续结算,不能因后台关闭计费而被当成 bypass 遗留冻结。
+ # 固定预扣已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
+ # 不再读取旧 SystemConfig 冻结配置;缺少固定预扣记录时直接终止恢复。
ledger_states = await get_llm_ledger_states(db, contexts.values())
- output: dict[str, LlmHoldValidation] = {}
+ output: dict[str, LlmPreDeductValidation] = {}
for step_id, ctx in contexts.items():
- policy = policies.get(ctx.hold_config_key)
ledger = ledger_states.get(
- ctx.hold_biz_key,
- LlmHoldValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
+ ctx.pre_deduct_biz_key,
+ LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
)
if ledger.state == LlmBillingLedgerState.ACTIVE:
output[step_id] = ledger
- elif ledger.state == LlmBillingLedgerState.MISSING and policy is not None and policy.bypassed:
- output[step_id] = LlmHoldValidation(
- True,
- 0.0,
- LlmBillingLedgerState.BILLING_BYPASSED,
- "billing_disabled",
- )
- elif ledger.state == LlmBillingLedgerState.MISSING and (policy is None or not policy.valid):
- output[step_id] = LlmHoldValidation(
+ elif ledger.state == LlmBillingLedgerState.MISSING:
+ output[step_id] = LlmPreDeductValidation(
False,
0.0,
LlmBillingLedgerState.INVALID,
diff --git a/video-gen-api/app/services/module_generation_v2/flow_service.py b/video-gen-api/app/services/module_generation_v2/flow_service.py
index 0be13454..90d9ff28 100644
--- a/video-gen-api/app/services/module_generation_v2/flow_service.py
+++ b/video-gen-api/app/services/module_generation_v2/flow_service.py
@@ -19,7 +19,6 @@ from app.enums.common import (
)
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.generation_task import ChatGenerationTaskStatus
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ShotSegmentReplicateStatusEnum,
ShotSplitStatusEnum,
@@ -51,13 +50,14 @@ from app.services.generation.pipeline.db_lock_service import (
from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.llm_billing import (
LlmBillingContext,
- ensure_hold_exists,
+ ensure_pre_deducted,
log_provider_failure,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
- start_hold,
+ mark_business_success,
+ pre_deduct,
+ record_provider_exception,
+ finalize_llm_business_failure,
)
from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt,
@@ -369,8 +369,7 @@ def build_v2_video_prompt_billing_context(
source_step_id=str(step_id),
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=str(step_id),
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
- description_prefix=f"{display_name}视频提词优化",
+ description_prefix=f"{display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{step_id}",
)
@@ -433,7 +432,7 @@ async def create_hot_opening_project_v2(
video_config=video_config,
target_platform=req.target_platform or "抖音",
)
- await start_hold(
+ await pre_deduct(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -600,7 +599,7 @@ async def create_shot_replicate_project_v2(
urls=[req.material_image_url],
allow_common_migrate=True,
)
- await start_hold(
+ await pre_deduct(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -724,7 +723,7 @@ async def rebuild_video_prompt_step_v2(
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
- await start_hold(
+ await pre_deduct(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
@@ -799,19 +798,16 @@ async def mark_video_prompt_dispatch_failed_v2(
message=error_message,
detail={"dispatch_compensated": True},
)
- await release_on_failure(
- db,
- build_v2_video_prompt_billing_context(
- user_id=str(project.user_id),
- project_id=str(project.id),
- step_id=str(step.id),
- step_version=int(step.version or 1),
- module=config.module,
- display_name=config.display_name,
- ),
- error=error_message,
+ billing_context = build_v2_video_prompt_billing_context(
+ user_id=str(project.user_id),
+ project_id=str(project.id),
+ step_id=str(step.id),
+ step_version=int(step.version or 1),
+ module=config.module,
+ display_name=config.display_name,
)
await db.commit()
+ await finalize_llm_business_failure(billing_context, error=error_message)
async def run_video_prompt_optimize_v2(
@@ -888,14 +884,13 @@ async def run_video_prompt_optimize_v2(
source_step_id=project_snapshot["step_id"],
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=project_snapshot["step_id"],
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{config.display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
- step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
+ step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -906,7 +901,7 @@ async def run_video_prompt_optimize_v2(
provider_succeeded = False
usage: dict[str, Any] = {}
- log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
+ await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt(
db,
user_id=project_snapshot["user_id"],
@@ -921,9 +916,11 @@ async def run_video_prompt_optimize_v2(
module=project_snapshot["module"],
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=usage)
+ await log_provider_success(db, llm_billing_context, usage=usage)
if execution_guard is not None:
await execution_guard()
@@ -945,24 +942,20 @@ async def run_video_prompt_optimize_v2(
row = locked.first()
if not row:
await db.rollback()
- # Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。
- await settle_success(
- db,
+ await log_provider_failure(db, llm_billing_context, error="业务对象已失效,供应商结果无法落库")
+ await finalize_llm_business_failure(
llm_billing_context,
- usage=usage,
- description=f"{config.display_name}-视频提词优化(业务对象失效结算)",
+ error="业务对象已失效,供应商结果无法落库",
)
- await db.commit()
return None
project, step = row
if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value:
- await settle_success(
- db,
+ await db.rollback()
+ await log_provider_failure(db, llm_billing_context, error="业务步骤版本已失效,供应商结果被丢弃")
+ await finalize_llm_business_failure(
llm_billing_context,
- usage=usage,
- description=f"{config.display_name}-视频提词优化(失效结果结算)",
+ error="业务步骤版本已失效,供应商结果被丢弃",
)
- await db.commit()
log_module_event_file(
module=project_snapshot["module"],
event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value,
@@ -973,7 +966,7 @@ async def run_video_prompt_optimize_v2(
detail={"expected_version": expected_version},
)
return None
- billing = await settle_success(
+ billing = await mark_business_success(
db,
llm_billing_context,
usage=usage,
@@ -1018,33 +1011,24 @@ async def run_video_prompt_optimize_v2(
)
await db.commit()
return step
- except DatabaseRowLockBusy:
+ except DatabaseRowLockBusy as exc:
await db.rollback()
- if locals().get("provider_succeeded", False):
- await settle_success(
- db,
+ if "llm_billing_context" in locals() and locals().get("provider_succeeded", False):
+ await log_provider_failure(db, llm_billing_context, error="业务行锁失败,供应商结果无法落库")
+ await finalize_llm_business_failure(
llm_billing_context,
- usage=locals().get("usage") or {},
- description=f"{config.display_name}-视频提词优化(行锁失败结算)",
+ error="业务行锁失败,供应商结果无法落库",
)
- await db.commit()
return None
- raise
+ raise exc
except Exception as exc:
await db.rollback()
- if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
- log_provider_failure(llm_billing_context, error=str(exc))
+ if "llm_billing_context" in locals():
+ if locals().get("provider_succeeded", False):
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, usage = await record_provider_exception(db, llm_billing_context, exc)
try:
- if "llm_billing_context" in locals():
- if locals().get("provider_succeeded", False):
- await settle_success(
- db,
- llm_billing_context,
- usage=locals().get("usage") or {},
- description=f"{config.display_name}-视频提词优化(本地失败结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error=str(exc))
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
@@ -1070,9 +1054,9 @@ async def run_video_prompt_optimize_v2(
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = str(exc) if str(exc) else type(exc).__name__
- # provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE;
- # 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。
await db.commit()
+ if "llm_billing_context" in locals():
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
log_module_error(
module=row[0].module if row else "module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
@@ -1092,8 +1076,11 @@ async def run_video_prompt_optimize_v2(
detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)},
exc=mark_exc,
)
- if locals().get("provider_succeeded", False):
- raise
+ try:
+ if "llm_billing_context" in locals():
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
+ except Exception:
+ pass
return None
diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py
index bc269881..3c267a1d 100644
--- a/video-gen-api/app/services/payment.py
+++ b/video-gen-api/app/services/payment.py
@@ -16,6 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.payment_order import PaymentOrder
+from app.models.credit.product import CreditProduct
+from app.models.user import User
from app.models.system_config import SystemConfig
from app.enums.credit_record import (
CreditRecordAction,
@@ -27,6 +29,10 @@ from app.enums.credit_record import (
)
from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta
from app.services.credits import add_credits, deduct_credits
+from app.services.credit.product_service import product_to_dict
+from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
+from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
+from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id, generate_order_no
@@ -175,6 +181,8 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
expiry = order.created_at + timedelta(seconds=expire_seconds)
if datetime.now(order.created_at.tzinfo) >= expiry:
order.status = "cancelled"
+ if order.upgrade_period_ids_json:
+ await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
@@ -212,6 +220,8 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
expired_count = 0
for o in orders:
o.status = "cancelled"
+ if o.upgrade_period_ids_json:
+ await release_upgrade_reservation(db, order=o, released_at=utc_now())
expired_count += 1
logger.info(
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
@@ -271,7 +281,7 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
config.alipay_public_key = public_key
config.sign_type = "RSA2"
config.charset = "utf-8"
-
+
try:
_alipay_client = DefaultAlipayClient(config, logger)
_alipay_client_app_id = app_id
@@ -291,23 +301,18 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
async def create_recharge_order(
db: AsyncSession,
user_id: str,
- credits: float,
- price: float,
- label: str,
+ credits: float = 0.0,
+ price: float = 0.0,
+ label: str = "积分商品",
bonus_credits: float = 0.0,
method: str = "wechat",
+ *,
+ product_id: str | None = None,
+ request_time: datetime | None = None,
) -> PaymentOrder:
- """Create a payment order.
-
- Reads payment config from the database (admin panel).
- Returns the order; for Alipay the ``qr_url`` attribute will be populated
- with the scan-to-pay URL.
- """
- # Read config from database first
+ """创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
-
- # In real mode, validate that the payment method is enabled and configured
if not mock_mode:
enabled_key = f"payment_{method}_enabled"
if db_configs.get(enabled_key, "").lower() != "true":
@@ -321,69 +326,120 @@ async def create_recharge_order(
"payment_wechat_mch_id",
"payment_wechat_private_key",
"payment_wechat_cert_serial_no",
- "payment_wechat_api_v3_key"
+ "payment_wechat_api_v3_key",
]
- missing_configs = [c for c in required_configs if not db_configs.get(c)]
+ missing_configs = [key for key in required_configs if not db_configs.get(key)]
if missing_configs:
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
- total_credits = credits + bonus_credits
+ checked_at = request_time or utc_now()
+ order_id = generate_id()
+ order_no = generate_order_no()
+ product: CreditProduct | None = None
+ quote = None
+
+ # 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
+ # 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
order = PaymentOrder(
- id=generate_id(),
+ id=order_id,
user_id=user_id,
- order_no=generate_order_no(),
+ order_no=order_no,
amount=price,
- credits=total_credits,
+ credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
payment_method=method,
status="pending",
+ purchase_scene="legacy_recharge",
+ price_type="regular",
+ product_name_snapshot=label,
+ target_price_snapshot=price,
+ deduction_amount_snapshot=0,
+ payable_amount_snapshot=price,
)
db.add(order)
await db.flush()
+
+ if product_id:
+ product_result = await db.execute(
+ select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1)
+ )
+ product = product_result.scalar_one_or_none()
+ if product is None:
+ raise ValueError("积分商品不存在或已下架")
+ # 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。
+ # 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。
+ user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
+ user = user_result.scalar_one_or_none()
+ if user is None:
+ raise ValueError("用户不存在")
+ quote = await quote_and_reserve_product_purchase(
+ db, user=user, product=product, order_id=order.id, request_time=checked_at
+ )
+ price = float(quote.payable_amount)
+ label = product.name
+ credits = float(product.grant_credits or product.monthly_grant_credits or 0)
+ bonus_credits = 0.0
+ order.amount = quote.payable_amount
+ order.credits = round(float(credits or 0), 2)
+ order.product_id = product.id
+ order.product_type = product.product_type
+ order.purchase_scene = quote.purchase_scene
+ order.price_type = quote.price_type
+ order.product_code_snapshot = product.product_code
+ order.product_name_snapshot = product.name
+ product_snapshot = product_to_dict(product)
+ for time_key in ("activity_start_at", "activity_end_at"):
+ value = product_snapshot.get(time_key)
+ if value is not None:
+ product_snapshot[time_key] = value.isoformat()
+ order.product_snapshot_json = product_snapshot
+ order.source_subscription_id = quote.source_subscription_id
+ order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None
+ order.target_price_snapshot = quote.target_price
+ order.deduction_amount_snapshot = quote.deduction_amount
+ order.payable_amount_snapshot = quote.payable_amount
+ order.fulfillment_status = "pending"
+
+ total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2)
+ await db.flush()
logger.info(
- f"ORDER_CREATED order_no={order.order_no} user={user_id} "
- f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
+ f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
+ f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
)
if mock_mode:
- # Mock: immediately complete payment
order.status = "paid"
- order.paid_at = datetime.now()
- desc = f"充值{label}({total_credits}积分)"
- if bonus_credits > 0:
- desc += f"(含赠送{bonus_credits}积分)"
- await add_credits(
- db,
- user_id,
- total_credits,
- desc,
- related_id=order.id,
- biz_key=_payment_biz_key(
- order,
- charge_kind=CreditRecordChargeKind.RECHARGE.value,
- action=CreditRecordAction.CHARGE.value,
- ),
- record_meta=_payment_recharge_meta(order),
- )
+ order.paid_at = checked_at
+ if product:
+ await fulfill_payment_product(db, order=order, fulfilled_at=checked_at)
+ else:
+ desc = f"充值{label}({total_credits}积分)"
+ if bonus_credits > 0:
+ desc += f"(含赠送{bonus_credits}积分)"
+ await add_credits(
+ db, user_id, total_credits, desc, related_id=order.id,
+ biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
+ record_meta=_payment_recharge_meta(order),
+ payment_order_id=order.id,
+ source_id=order.id,
+ )
await db.flush()
else:
- # Real payment: delegate to WeChat or Alipay
if method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if qr_code_content:
- # Attach QR code content to the order instance (transient, not persisted)
order.qr_url = qr_code_content # type: ignore[attr-defined]
else:
- # Precreate failed — do not leave a pending order that can never be paid
+ if quote and quote.upgrade_period_ids:
+ await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if qr_url:
- # Attach QR URL to the order instance (transient, not persisted)
order.qr_url = qr_url # type: ignore[attr-defined]
else:
- # Precreate failed — do not leave a pending order that can never be paid
+ if quote and quote.upgrade_period_ids:
+ await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
-
return order
@@ -471,7 +527,7 @@ def _get_wechat_client(
def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None:
"""Create a WeChat Pay Native order. Returns QR code content (code_url).
-
+
Reads all WeChat config from the database (admin panel).
Returns the ``code_url`` on success, or ``None`` on failure.
"""
@@ -505,9 +561,9 @@ def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
"payer_client_ip": "127.0.0.1",
}
)
-
+
data = _parse_wechat_result(result)
-
+
if code == 200 and data.get('code_url'):
# 注意:微信返回的 code_url 可能需要进一步处理成二维码图片地址
logger.info(f"WeChat order created successfully: order_no={order.order_no}")
@@ -608,7 +664,7 @@ async def _refund_wechat_order(
db_configs: dict[str, str]
) -> dict:
"""Call WeChat Pay refund API.
-
+
微信退款是异步的,调用后会返回PROCESSING状态,实际退款结果通过回调通知。
"""
mch_id = db_configs.get("payment_wechat_mch_id", "")
@@ -641,9 +697,9 @@ async def _refund_wechat_order(
},
reason=refund_reason
)
-
+
data = _parse_wechat_result(result)
-
+
# 微信退款是异步的,PROCESSING是正常状态,表示退款已受理
if code == 200:
status = data.get('status')
@@ -653,18 +709,18 @@ async def _refund_wechat_order(
f"status={status}, refund_id={data.get('refund_id')}"
)
return {
- "success": True,
+ "success": True,
"refund_id": data.get('refund_id'),
"status": status,
"message": "退款申请已提交,等待微信处理"
}
-
+
logger.error(
f"WeChat refund failed: order_no={order.order_no}, "
f"code={code}, result={result}"
)
return {
- "success": False,
+ "success": False,
"message": f"微信退款失败: code={code}, {data.get('message', '')}"
}
except Exception as e:
@@ -723,7 +779,7 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
# 构造请求
request = AlipayTradePrecreateRequest(biz_model=model)
-
+
# 设置 notify_url 在 request 上
if notify_url:
try:
@@ -925,6 +981,8 @@ async def sync_pending_orders(db: AsyncSession) -> int:
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
# Order was closed on Alipay side
order.status = "cancelled"
+ if order.upgrade_period_ids_json:
+ await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
updated_count += 1
elif order.payment_method == "wechat":
@@ -940,6 +998,8 @@ async def sync_pending_orders(db: AsyncSession) -> int:
elif trade_state in ("CLOSED", "REVOKED"):
# Order was closed on WeChat side
order.status = "cancelled"
+ if order.upgrade_period_ids_json:
+ await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
updated_count += 1
except Exception as e:
@@ -1010,43 +1070,43 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type: str = "RSA2") -> bool:
"""Verify Alipay RSA/RSA2 signature.
-
+
Args:
public_key: Alipay public key (PEM format, with or without headers)
sign_content: Original content to verify
sign: Base64 encoded signature
sign_type: "RSA" (SHA1) or "RSA2" (SHA256)
-
+
Returns:
True if signature is valid
"""
try:
import base64
from hashlib import sha1, sha256
-
+
# 处理公钥,确保有正确的格式
pub_key = public_key.strip()
if not pub_key.startswith("-----BEGIN"):
pub_key = "-----BEGIN PUBLIC KEY-----\n" + pub_key + "\n-----END PUBLIC KEY-----"
-
+
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
-
+
# 加载公钥
public_key_obj = serialization.load_pem_public_key(
pub_key.encode("utf-8"),
backend=default_backend()
)
-
+
# 选择哈希算法
if sign_type == "RSA2":
hash_alg = hashes.SHA256()
else:
hash_alg = hashes.SHA1()
-
+
# 验证签名
public_key_obj.verify(
base64.b64decode(sign),
@@ -1055,21 +1115,21 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type
hash_alg
)
return True
-
+
except ImportError:
# 如果没有 cryptography,尝试使用 rsa 库
try:
import rsa
-
+
# 加载公钥
pub_key_obj = rsa.PublicKey.load_pkcs1_openssl_pem(pub_key.encode("utf-8"))
-
+
# 选择哈希算法
if sign_type == "RSA2":
hash_func = 'SHA-256'
else:
hash_func = 'SHA-1'
-
+
# 验证签名
rsa.verify(
sign_content.encode("utf-8"),
@@ -1078,13 +1138,13 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type
hash_func
)
return True
-
+
except ImportError:
logger.error("Neither cryptography nor rsa library installed, cannot verify signature")
# 如果没有任何加密库,在生产环境应该返回 False,但这里我们记录警告并继续
logger.warning("Skipping signature verification due to missing crypto libraries")
return False
-
+
except Exception as e:
logger.exception(f"Signature verification failed: {e}")
return False
@@ -1097,7 +1157,7 @@ def _verify_alipay_sign(public_key: str, sign_content: str, sign: str, sign_type
async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
"""Verify WeChat payment callback signature.
-
+
Note: Since we're using wechatpayv3 SDK which handles verification internally,
in the callback handler we'll verify with the SDK. This function is kept for
interface consistency and mock mode support.
@@ -1107,7 +1167,7 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
if mock_mode:
logger.info("Mock mode: skipping WeChat callback verification")
return True
-
+
# 对于真实模式,我们在回调路由处理器中直接使用 SDK 验证
# 这里我们返回 True 以保持接口一致性
logger.info("WeChat callback verification (delegated to SDK in router)")
@@ -1130,19 +1190,17 @@ async def process_payment_success(db: AsyncSession, order_id: str):
order.status = "paid"
order.paid_at = datetime.now()
- await add_credits(
- db,
- order.user_id,
- order.credits,
- f"充值成功({order.credits}积分)",
- related_id=order.id,
- biz_key=_payment_biz_key(
- order,
- charge_kind=CreditRecordChargeKind.RECHARGE.value,
- action=CreditRecordAction.CHARGE.value,
- ),
- record_meta=_payment_recharge_meta(order),
- )
+ if order.product_id:
+ await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
+ else:
+ await add_credits(
+ db, order.user_id, order.credits, f"充值成功({order.credits}积分)",
+ related_id=order.id,
+ biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
+ record_meta=_payment_recharge_meta(order),
+ payment_order_id=order.id,
+ source_id=order.id,
+ )
await db.commit()
@@ -1164,21 +1222,21 @@ async def process_payment_success_by_order_no(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
-
+
if not order:
logger.info(f"Order {order_no} not found, skipping")
return
-
+
if order.status == "paid":
logger.info(f"Order {order_no} already processed, skipping")
return
-
+
if order.status != "pending":
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
return
# 金额一致性校验
- if total_amount is not None and abs(total_amount - order.amount) > 0.01:
+ if total_amount is not None and abs(to_credit_decimal(total_amount) - to_credit_decimal(order.amount)) > to_credit_decimal("0.01"):
logger.error(
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
)
@@ -1194,19 +1252,18 @@ async def process_payment_success_by_order_no(
if trade_no:
order.trade_no = trade_no
- await add_credits(
- db,
- order.user_id,
- order.credits,
- f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}",
- related_id=order.id,
- biz_key=_payment_biz_key(
- order,
- charge_kind=CreditRecordChargeKind.RECHARGE.value,
- action=CreditRecordAction.CHARGE.value,
- ),
- record_meta=_payment_recharge_meta(order),
- )
+ if order.product_id:
+ await fulfill_payment_product(db, order=order, fulfilled_at=order.paid_at)
+ else:
+ await add_credits(
+ db, order.user_id, order.credits,
+ f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}",
+ related_id=order.id,
+ biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
+ record_meta=_payment_recharge_meta(order),
+ payment_order_id=order.id,
+ source_id=order.id,
+ )
await db.commit()
logger.info(
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
@@ -1235,22 +1292,22 @@ async def process_refund(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
-
+
if not order:
return {"success": False, "message": "订单不存在"}
-
+
if order.status != "paid":
return {"success": False, "message": f"订单状态为{order.status},无法退款"}
-
+
if order.refunded_at is not None:
return {"success": False, "message": "订单已退款"}
- refund_amount = refund_amount or order.amount
-
+ refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
+
# 金额校验
- if refund_amount > order.amount:
+ if refund_amount > to_credit_decimal(order.amount):
return {"success": False, "message": "退款金额超过订单金额"}
-
+
# 根据支付方式调用相应的退款API
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
@@ -1265,38 +1322,33 @@ async def process_refund(
)
if not refund_result.get("success"):
return refund_result
-
- # 扣除积分
+
+ # 按原支付业务位置适配新积分账本;不改变支付渠道退款流程。
try:
- await deduct_credits(
- db,
- order.user_id,
- order.credits,
- refund_reason,
- related_id=order.id,
- biz_key=_payment_biz_key(
- order,
- charge_kind=CreditRecordChargeKind.REFUND.value,
- action=CreditRecordAction.REFUND.value,
- ),
- refund_for_biz_key=_payment_biz_key(
- order,
- charge_kind=CreditRecordChargeKind.RECHARGE.value,
- action=CreditRecordAction.CHARGE.value,
- ),
- record_meta=_payment_refund_meta(order),
- )
+ if order.product_id:
+ await revoke_payment_order_credits(db, order=order, reason=refund_reason)
+ else:
+ await deduct_credits(
+ db,
+ order.user_id,
+ order.credits,
+ refund_reason,
+ related_id=order.id,
+ biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value),
+ refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
+ record_meta=_payment_refund_meta(order),
+ )
except Exception as e:
logger.exception(f"Failed to deduct credits for refund: {e}")
return {"success": False, "message": "积分扣除失败"}
-
+
# 更新订单状态
order.status = "refunded"
order.refund_amount = refund_amount
order.refunded_at = datetime.now()
if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "")
-
+
await db.commit()
logger.info(
f"REFUND_SUCCESS order_no={order_no} user={order.user_id} "
@@ -1340,7 +1392,7 @@ async def _refund_alipay_order(
request = AlipayTradeRefundRequest(biz_model=model)
response_content = client.execute(request)
-
+
if not response_content:
logger.error(f"Alipay refund failed: empty response, order_no={order.order_no}")
return {"success": False, "message": "支付宝退款响应为空"}
diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py
index bfe232f8..73734180 100644
--- a/video-gen-api/app/services/shot_replicate_flow_service.py
+++ b/video-gen-api/app/services/shot_replicate_flow_service.py
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -59,13 +58,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
- ensure_hold_exists,
+ ensure_pre_deducted,
log_provider_failure,
+ record_provider_exception,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
- start_hold,
+ refund_on_final_failure,
+ finalize_llm_business_failure,
+ mark_business_success,
+ pre_deduct,
)
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
@@ -841,7 +842,7 @@ async def submit_image_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.error_message = None
- await start_hold(
+ await pre_deduct(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -855,9 +856,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
- trace_id=f"llm-submit-hold:{step.id}",
+ trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -954,14 +954,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
- description_prefix="拆镜复刻图片AI提词优化",
+ description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"shot-image-prompt:{step_id_value}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
- step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
+ step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -972,7 +971,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
- log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
+ await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -997,9 +996,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=token_usage)
+ await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1013,15 +1014,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-图片AI提词优化(失效结果结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
- billing = await settle_success(
+ billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1069,21 +1065,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
- # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-图片AI提词优化(行锁失败结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
+ await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
- if not provider_succeeded:
- log_provider_failure(llm_billing_context, error=str(exc))
+ if provider_succeeded:
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
+ if recovered_usage:
+ token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1098,16 +1092,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-图片AI提词优化(异常失效结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
- await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1126,16 +1111,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-图片AI提词优化(本地失败结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1309,7 +1286,7 @@ async def submit_video_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.error_message = None
- await start_hold(
+ await pre_deduct(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1323,9 +1300,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
- trace_id=f"llm-submit-hold:{step.id}",
+ trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1420,14 +1396,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
- hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
- description_prefix="拆镜复刻视频AI提词优化",
+ description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"shot-video-prompt:{step_id_value}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
- step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
+ step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1438,7 +1413,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
- log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
+ await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "无",
@@ -1475,9 +1450,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"shot-video-prompt:{step_id_value}",
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=token_usage)
+ await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1491,15 +1468,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-视频AI提词优化(失效结果结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
- billing = await settle_success(
+ billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1550,21 +1522,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
- # Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-视频AI提词优化(行锁失败结算)",
- )
- await db.commit()
+ await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
+ await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
- if not provider_succeeded:
- log_provider_failure(llm_billing_context, error=str(exc))
+ if provider_succeeded:
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
+ if recovered_usage:
+ token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1579,16 +1549,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-视频AI提词优化(异常失效结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
- await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1607,16 +1568,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
- if provider_succeeded:
- await settle_success(
- db,
- llm_billing_context,
- usage=token_usage,
- description="拆镜复刻-视频AI提词优化(本地失败结算)",
- )
- else:
- await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
+ await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1937,7 +1890,7 @@ async def mark_shot_replicate_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
- await release_on_failure(
+ await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1955,11 +1908,6 @@ async def mark_shot_replicate_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
- hold_config_key=(
- LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
- if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
- else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
- ),
description_prefix=(
"拆镜复刻图片AI提词优化"
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
diff --git a/video-gen-api/app/services/shot_replicate_recovery_service.py b/video-gen-api/app/services/shot_replicate_recovery_service.py
index ff828bb9..1c080eb9 100644
--- a/video-gen-api/app/services/shot_replicate_recovery_service.py
+++ b/video-gen-api/app/services/shot_replicate_recovery_service.py
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
-from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
+from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.shot_replicate import ShotSplitStatusEnum
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
@@ -19,7 +19,6 @@ from app.services.shot_replicate_taskset_service import (
refresh_task_set_split_summaries,
)
from app.services.llm_billing import get_llm_ledger_states
-from app.services.llm_billing.config import get_llm_billing_policy
from app.services.celery_runtime.runtime_service import runtime_lock_values
from app.tasks.celery_app import celery_app
@@ -230,15 +229,11 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
)
segments = list(segment_result.scalars().all())
- billing_policy = await get_llm_billing_policy(
- db,
- config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- )
billing_contexts = [
*(build_task_set_analysis_billing_context(item) for item in task_sets),
*(build_segment_analysis_billing_context(item) for item in segments),
]
- # 配置关闭后仍需识别并继续处理已存在的 active HOLD;只有 missing 流水才按 bypass。
+ # 固定预扣配置变更后仍需识别并继续处理已存在的有效预扣;缺少预扣记录时禁止恢复。
billing_states = await get_llm_ledger_states(db, billing_contexts)
lock_keys: list[str] = []
@@ -264,13 +259,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1
continue
context = build_task_set_analysis_billing_context(item)
- validation = billing_states.get(context.hold_biz_key)
+ validation = billing_states.get(context.pre_deduct_biz_key)
can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
- if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed:
- can_execute = True
- state = LlmBillingLedgerState.BILLING_BYPASSED.value
- elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
+ if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotAnalysisStatusEnum.FAILED.value
@@ -297,13 +289,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1
continue
context = build_segment_analysis_billing_context(item)
- validation = billing_states.get(context.hold_biz_key)
+ validation = billing_states.get(context.pre_deduct_biz_key)
can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
- if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed:
- can_execute = True
- state = LlmBillingLedgerState.BILLING_BYPASSED.value
- elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
+ if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
diff --git a/video-gen-api/app/services/shot_replicate_taskset_service.py b/video-gen-api/app/services/shot_replicate_taskset_service.py
index ea30cd4a..89b5d4b5 100644
--- a/video-gen-api/app/services/shot_replicate_taskset_service.py
+++ b/video-gen-api/app/services/shot_replicate_taskset_service.py
@@ -16,7 +16,6 @@ from app.enums.credit_record import (
CreditRecordSourceModule,
CreditRecordSourceStepCode,
)
-from app.enums.llm_billing import LlmBillingConfigKey
from sqlalchemy import String, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -56,8 +55,8 @@ from app.schemas.shot_replicate import (
from app.services.module_generation_log_service import log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
- release_on_failure,
- start_hold,
+ refund_on_final_failure,
+ pre_deduct,
validate_retryable_previous_attempt,
)
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
@@ -94,8 +93,7 @@ def build_task_set_analysis_billing_context(task_set: ShotReplicateTaskSet) -> L
source_step_id=str(task_set.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(task_set.id),
- hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- description_prefix="拆镜复刻原视频AI分析",
+ description_prefix="拆镜复刻原视频AI分析",
trace_id=f"shot-task-set-analysis:{task_set.id}:attempt:{int(task_set.analysis_attempt_no or 1)}",
)
@@ -113,8 +111,7 @@ def build_segment_analysis_billing_context(segment: ShotReplicateSegment) -> Llm
source_step_id=str(segment.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(segment.id),
- hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- description_prefix="拆镜复刻片段视频AI分析",
+ description_prefix="拆镜复刻片段视频AI分析",
trace_id=f"shot-segment-analysis:{segment.id}:attempt:{int(segment.analysis_attempt_no or 1)}",
)
@@ -268,7 +265,7 @@ async def create_task_set(
)
db.add(task_set)
await db.flush()
- await start_hold(db, build_task_set_analysis_billing_context(task_set))
+ await pre_deduct(db, build_task_set_analysis_billing_context(task_set))
log_module_event_file(
module=MODULE,
event_type="SHOT_TASK_SET_CREATED",
@@ -751,7 +748,7 @@ async def create_custom_segment(
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
await db.flush()
- await start_hold(db, build_segment_analysis_billing_context(segment))
+ await pre_deduct(db, build_segment_analysis_billing_context(segment))
await refresh_task_set_split_summary(db, task_set.id)
await db.flush()
log_module_event_file(
@@ -920,7 +917,7 @@ async def delete_segment(
raise HTTPException(status_code=409, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除")
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
- await release_on_failure(
+ await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除自定义拆镜片段,释放未结算的片段分析预扣",
@@ -982,7 +979,7 @@ async def delete_segment(
"pending_delete_resource_count": len(pending_delete_resource_ids),
"physical_file_delete": "after_commit",
"media_refund": False,
- "llm_hold_release_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
+ "llm_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
},
)
@@ -1037,14 +1034,14 @@ async def delete_task_set(
# 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。
# 这里仅做账务补偿,不改变现有逐项目删除流程。
- await release_on_failure(
+ await refund_on_final_failure(
db,
build_task_set_analysis_billing_context(task_set),
error="用户删除拆镜任务集,释放未结算的原视频分析预扣",
)
for segment in segments:
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
- await release_on_failure(
+ await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除拆镜任务集,释放未结算的片段分析预扣",
@@ -1113,7 +1110,7 @@ async def delete_task_set(
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
"physical_file_delete": "after_commit",
"media_refund": False,
- "llm_hold_release_on_cancel": True,
+ "llm_pre_deduct_refund_on_cancel": True,
},
)
return ShotTaskSetDeleteOut(
@@ -1198,7 +1195,7 @@ async def prepare_reanalyze_task_set(
task_set.analysis_raw_json = None
task_set.analysis_result_json = None
await db.flush()
- await start_hold(db, build_task_set_analysis_billing_context(task_set))
+ await pre_deduct(db, build_task_set_analysis_billing_context(task_set))
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value,
@@ -1304,7 +1301,7 @@ async def prepare_reanalyze_segment(
segment.segment_category = None
segment.segment_audience = None
await db.flush()
- await start_hold(db, build_segment_analysis_billing_context(segment))
+ await pre_deduct(db, build_segment_analysis_billing_context(segment))
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value,
@@ -1357,7 +1354,7 @@ async def mark_task_set_analysis_dispatch_failed(
task_set.analysis_claim_token = None
task_set.analysis_lease_until = None
task_set.analysis_error_message = error_message
- await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
+ await refund_on_final_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1394,7 +1391,7 @@ async def mark_segment_analysis_dispatch_failed(
segment.analysis_claim_token = None
segment.analysis_lease_until = None
segment.analysis_error_message = error_message
- await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
+ await refund_on_final_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1423,7 +1420,7 @@ async def mark_custom_segment_split_dispatch_failed(
segment.split_next_retry_at = None
segment.split_last_error = error_message
# 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用
- # 原 active HOLD;只有片段分析最终失败或用户删除片段时才释放。
+ # 原固定预扣;只有片段分析最终失败或用户删除片段时才按原来源退款。
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
diff --git a/video-gen-api/app/services/shot_video_analysis_service.py b/video-gen-api/app/services/shot_video_analysis_service.py
index 255c5edf..de5fc542 100644
--- a/video-gen-api/app/services/shot_video_analysis_service.py
+++ b/video-gen-api/app/services/shot_video_analysis_service.py
@@ -17,6 +17,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
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
+from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
AnalysisMode = Literal["full_breakdown", "summary_only"]
@@ -411,13 +412,15 @@ def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode
return result
-async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
- result = await db.execute(
- select(ModelConfig)
- .where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
- .order_by(ModelConfig.priority.desc())
- .limit(1)
+async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
+ stmt = select(ModelConfig).where(
+ ModelConfig.is_active == True,
+ ModelConfig.deleted_at.is_(None),
+ ModelConfig.provider != "mock",
)
+ if model_config_id:
+ stmt = stmt.where(ModelConfig.id == model_config_id)
+ result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -559,6 +562,8 @@ async def analyze_video_for_shot_split(
task_set_id: str | None = None,
segment_id: str | None = None,
trace_id: str | None = None,
+ fixed_model_config_id: str | None = None,
+ fixed_model_snapshot: dict[str, Any] | None = None,
) -> ShotVideoAnalysisResult:
"""调用模型完成拆镜/片段分析。
@@ -568,19 +573,20 @@ async def analyze_video_for_shot_split(
"""
trace_id = trace_id or generate_id()
call_id = generate_id()
- config_row = await _select_model_config(db)
+ config_row = await _select_model_config(db, fixed_model_config_id)
if not config_row:
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
# 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。
+ snapshot = dict(fixed_model_snapshot or {})
config = SimpleNamespace(
id=str(config_row.id),
- name=str(config_row.name or ""),
- provider=str(config_row.provider or ""),
- api_base=str(config_row.api_base or ""),
+ name=str(snapshot.get("name") or config_row.name or ""),
+ provider=str(snapshot.get("provider") or config_row.provider or ""),
+ api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
- model_name=str(config_row.model_name or ""),
- max_tokens=getattr(config_row, "max_tokens", None),
- temperature=getattr(config_row, "temperature", None),
+ model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
+ max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else getattr(config_row, "max_tokens", None),
+ temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else getattr(config_row, "temperature", None),
)
if not str(config.api_key or "").strip():
raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}")
@@ -610,8 +616,8 @@ async def analyze_video_for_shot_split(
{"role": "system", "content": system_prompt},
user_message,
],
- "max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000),
- "temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1),
+ "max_tokens": int((getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or 5000)),
+ "temperature": float((getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or 0.1)),
"response_format": {"type": "json_object"},
}
log_request_data: dict[str, Any] = {
@@ -729,33 +735,6 @@ async def analyze_video_for_shot_split(
)
raise
- try:
- content = get_message_content_or_raise(raw)
- result = parse_model_json(content)
- except Exception as exc:
- event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
- _log_shot_ai_model_event(
- call_id=call_id,
- event_type=event_type,
- event_status=LogEventStatusEnum.FAILED.value,
- config=config,
- trace_id=trace_id,
- user_id=user_id,
- task_set_id=task_set_id,
- segment_id=segment_id,
- mode=mode,
- request_data={**log_request_data, "api_url": url},
- response_data=raw,
- http_status=response.status_code,
- message="拆镜视频分析模型内容解析失败",
- error=str(exc),
- )
- raise
-
- result = fill_none_with_wu(result)
- result = ensure_result_schema(result)
- result = filter_and_normalize_breakdown(result, mode=mode)
-
raw_usage = raw.get("usage")
usage_reported = bool(
isinstance(raw_usage, dict)
@@ -777,6 +756,8 @@ async def analyze_video_for_shot_split(
"analysis_mode": mode,
"trace_id": trace_id,
"usage_reported": usage_reported,
+ "provider_request_id": str(raw.get("id") or remote_request_id or "") or None,
+ "http_status": int(response.status_code),
}
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
@@ -788,6 +769,33 @@ async def analyze_video_for_shot_split(
"model_name": config.model_name,
})
+ try:
+ content = get_message_content_or_raise(raw)
+ result = parse_model_json(content)
+ result = fill_none_with_wu(result)
+ result = ensure_result_schema(result)
+ result = filter_and_normalize_breakdown(result, mode=mode)
+ except Exception as exc:
+ event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
+ _log_shot_ai_model_event(
+ call_id=call_id,
+ event_type=event_type,
+ event_status=LogEventStatusEnum.FAILED.value,
+ config=config,
+ trace_id=trace_id,
+ user_id=user_id,
+ task_set_id=task_set_id,
+ segment_id=segment_id,
+ mode=mode,
+ request_data={**log_request_data, "api_url": url},
+ response_data=raw,
+ token_usage=token_usage,
+ http_status=response.status_code,
+ message="拆镜视频分析模型内容后处理失败",
+ error=str(exc),
+ )
+ raise LlmProviderPostprocessError(f"拆镜视频分析响应后处理失败: {exc}", usage=token_usage) from exc
+
_log_shot_ai_model_event(
call_id=call_id,
event_type=(
diff --git a/video-gen-api/app/services/team_manager_service.py b/video-gen-api/app/services/team_manager_service.py
index c414586f..0067baff 100644
--- a/video-gen-api/app/services/team_manager_service.py
+++ b/video-gen-api/app/services/team_manager_service.py
@@ -1,25 +1,15 @@
from __future__ import annotations
-from datetime import datetime, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.enums.credit_record import (
- CreditRecordBillingScene,
- CreditRecordChargeKind,
- CreditRecordSubject,
- CreditRecordSourceModule,
-)
-from app.enums.team import TeamStatus
from app.enums.user import UserType
from app.models.team import Team
from app.models.user import User
-from app.services.credits import add_credits, deduct_credits
-from app.services.credit_record_meta_service import CreditRecordMeta
-from app.utils.id_gen import generate_id
+from app.services.credit.query_service import attach_credit_snapshot, get_user_credit_map
async def set_team_manager(db: AsyncSession, team_id: str, user_id: str | None) -> Team:
@@ -114,6 +104,9 @@ async def get_team_members(
.limit(page_size)
)
members = list(result.scalars().all())
+ credit_map = await get_user_credit_map(db, [item.id for item in members])
+ for item in members:
+ attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
@@ -139,104 +132,4 @@ async def transfer_credits_to_member(
direction: str = "increase", # "increase" 管理人→成员; "decrease" 成员扣减
description: str | None = None,
) -> None:
- """管理人为团队成员增加或扣减积分。
-
- direction:
- - "increase": 管理人从自己余额转积分给成员(管理人减少,成员增加)
- - "decrease": 从成员扣积分回到管理人(成员减少,管理人增加)
- """
- if amount <= 0:
- raise HTTPException(status_code=400, description="积分数量必须大于0")
- if direction not in ("increase", "decrease"):
- raise HTTPException(status_code=400, description="无效操作方向")
-
- # 获取管理人
- manager_result = await db.execute(
- select(User).where(User.id == manager_id, User.is_active.is_(True)).limit(1)
- )
- manager = manager_result.scalar_one_or_none()
- if not manager:
- raise HTTPException(status_code=404, detail="管理员不存在")
-
- # 获取目标成员
- member_result = await db.execute(
- select(User).where(User.id == target_member_id, User.is_active.is_(True)).limit(1)
- )
- member = member_result.scalar_one_or_none()
- if not member:
- raise HTTPException(status_code=404, detail="成员不存在")
-
- # 验证管理人是该团队的管理人且目标是同团队成员
- if not manager.team_id:
- raise HTTPException(status_code=400, detail="您不在任何团队中")
- if member.team_id != manager.team_id:
- raise HTTPException(status_code=400, detail="只能操作同团队成员")
-
- team_result = await db.execute(
- select(Team).where(Team.id == manager.team_id, Team.deleted_at.is_(None)).limit(1)
- )
- team = team_result.scalar_one_or_none()
- if not team or team.manager_id != manager_id:
- raise HTTPException(status_code=403, detail="只有团队管理人才能分配积分")
-
- # 禁止管理人给自己转积分
- if target_member_id == manager_id:
- raise HTTPException(status_code=400, detail="不能给自己调整积分")
-
- desc = description or ("团队积分发放" if direction == "increase" else "团队积分扣减")
- xfer_id = generate_id()
-
- # 构建团队内部转账的 meta,确保 team_id_snapshot 等字段被正确设置
- def _build_team_transfer_meta(uid: str) -> CreditRecordMeta:
- meta = CreditRecordMeta(
- owner_type="team_internal_transfer",
- owner_id=xfer_id,
- charge_kind=CreditRecordChargeKind.TEAM_INTERNAL.value,
- credit_subject=CreditRecordSubject.TEAM_INTERNAL.value,
- source_module=CreditRecordSourceModule.TEAM.value,
- billing_scene=CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value,
- )
- return meta
-
- if direction == "increase":
- # 管理人扣减
- await deduct_credits(
- db,
- manager_id,
- amount,
- f"分配给成员 {member.username}: {desc}",
- record_type="team_internal",
- biz_key=f"mgr_xfer_out:{manager_id}:{target_member_id}:{xfer_id}",
- record_meta=_build_team_transfer_meta(manager_id),
- )
- # 成员增加
- await add_credits(
- db,
- target_member_id,
- amount,
- f"来自团队管理人: {desc}",
- record_type="team_internal",
- biz_key=f"mgr_xfer_in:{manager_id}:{target_member_id}:{xfer_id}",
- record_meta=_build_team_transfer_meta(target_member_id),
- )
- else:
- # 成员扣减
- await deduct_credits(
- db,
- target_member_id,
- amount,
- f"扣减给团队管理人: {desc}",
- record_type="team_internal",
- biz_key=f"mgr_deduct_out:{manager_id}:{target_member_id}:{xfer_id}",
- record_meta=_build_team_transfer_meta(target_member_id),
- )
- # 管理人增加
- await add_credits(
- db,
- manager_id,
- amount,
- f"来自成员 {member.username}: {desc}",
- record_type="team_internal",
- biz_key=f"mgr_deduct_in:{manager_id}:{target_member_id}:{xfer_id}",
- record_meta=_build_team_transfer_meta(manager_id),
- )
+ raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
diff --git a/video-gen-api/app/tasks/celery_app.py b/video-gen-api/app/tasks/celery_app.py
index 0425513f..3e1264f9 100644
--- a/video-gen-api/app/tasks/celery_app.py
+++ b/video-gen-api/app/tasks/celery_app.py
@@ -34,6 +34,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.module_generation_v2_tasks",
"app.tasks.private_portrait_asset_tasks",
"app.tasks.celery_runtime_tasks",
+ "app.tasks.credit_tasks",
)
@@ -120,6 +121,11 @@ def _beat_schedule() -> dict:
"schedule": 300,
"options": {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
}
+ schedule["credit-maintenance-every-minute"] = {
+ "task": CeleryTaskName.CREDIT_MAINTENANCE.value,
+ "schedule": 60,
+ "options": {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
+ }
return schedule
@@ -181,6 +187,7 @@ if broker_url:
CeleryTaskName.MODULE_ASYNC_RECOVERY.value: {"ignore_result": True},
CeleryTaskName.SHOT_ANALYSIS_RECOVERY.value: {"ignore_result": True},
CeleryTaskName.SHOT_SPLIT_RECOVERY.value: {"ignore_result": True},
+ CeleryTaskName.CREDIT_MAINTENANCE.value: {"ignore_result": True},
},
worker_prefetch_multiplier=1,
worker_cancel_long_running_tasks_on_connection_loss=True,
@@ -240,6 +247,7 @@ if broker_url:
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_GROUP.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_DELETE_PROJECT.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
CeleryTaskName.PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES.value: {"queue": CeleryQueue.GEN_PRIVATE_PORTRAIT.value},
+ CeleryTaskName.CREDIT_MAINTENANCE.value: {"queue": CeleryQueue.GEN_CREDIT_MAINTENANCE.value},
},
)
else:
diff --git a/video-gen-api/app/tasks/credit_tasks.py b/video-gen-api/app/tasks/credit_tasks.py
new file mode 100644
index 00000000..aff3ff10
--- /dev/null
+++ b/video-gen-api/app/tasks/credit_tasks.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+from typing import Any
+
+from app.models.base import async_session
+from app.services.credit.expiration_service import (
+ archive_expired_user_balances,
+ list_expired_balance_user_limits,
+)
+from app.services.credit.subscription_service import (
+ expire_subscription_by_id,
+ grant_due_subscription_period_by_id,
+ list_due_subscription_ids,
+ list_due_subscription_period_candidates,
+)
+from app.services.credit.utils import utc_now
+from app.services.operation_log_service import log_operation_event
+from app.tasks.async_runner import run_async
+from app.tasks.celery_app import celery_app
+
+
+async def _run_credit_maintenance_once(batch_size: int = 500) -> dict[str, Any]:
+ checked_at = utc_now()
+ limit = max(1, min(int(batch_size or 500), 2000))
+ errors: list[dict[str, str]] = []
+ log_operation_event(
+ domain="billing",
+ module="credit_maintenance",
+ event_type="CREDIT_MAINTENANCE_STARTED",
+ event_status="started",
+ source="app.tasks.credit_tasks._run_credit_maintenance_once",
+ message="积分维护批次开始",
+ detail={"checked_at": checked_at.isoformat(), "batch_size": limit},
+ )
+
+ async with async_session() as scan_db:
+ grant_candidates = await list_due_subscription_period_candidates(
+ scan_db, request_time=checked_at, batch_size=limit
+ )
+ subscription_ids = await list_due_subscription_ids(
+ scan_db, request_time=checked_at, batch_size=limit
+ )
+ expired_user_limits = await list_expired_balance_user_limits(
+ scan_db, request_time=checked_at, batch_size=limit
+ )
+ await scan_db.rollback()
+
+ def record_failure(stage: str, item_id: str, exc: Exception) -> None:
+ errors.append({"stage": stage, "id": item_id, "error": str(exc)})
+ log_operation_event(
+ domain="billing",
+ module="credit_maintenance",
+ event_type="CREDIT_MAINTENANCE_ITEM_FAILED",
+ event_status="failed",
+ source="app.tasks.credit_tasks._run_credit_maintenance_once",
+ task_id=item_id,
+ message="积分维护单条处理失败",
+ error=str(exc),
+ detail={"stage": stage, "item_id": item_id},
+ )
+
+ granted = 0
+ for period_id, subscription_id, user_id in grant_candidates:
+ async with async_session() as db:
+ try:
+ changed = await grant_due_subscription_period_by_id(
+ db,
+ period_id=period_id,
+ subscription_id=subscription_id,
+ user_id=user_id,
+ request_time=checked_at,
+ )
+ await db.commit()
+ granted += int(changed)
+ except Exception as exc:
+ await db.rollback()
+ record_failure("subscription_grant", period_id, exc)
+
+ expired_subscriptions = 0
+ for subscription_id in subscription_ids:
+ async with async_session() as db:
+ try:
+ changed = await expire_subscription_by_id(
+ db, subscription_id=subscription_id, request_time=checked_at
+ )
+ await db.commit()
+ expired_subscriptions += int(changed)
+ except Exception as exc:
+ await db.rollback()
+ record_failure("subscription_expire", subscription_id, exc)
+
+ expired = 0
+ for user_id, user_limit in expired_user_limits:
+ async with async_session() as db:
+ try:
+ changed = await archive_expired_user_balances(
+ db, user_id=user_id, request_time=checked_at, limit=user_limit
+ )
+ await db.commit()
+ expired += int(changed)
+ except Exception as exc:
+ await db.rollback()
+ record_failure("credit_expire", user_id, exc)
+
+ result = {
+ "checked_at": checked_at.isoformat(),
+ "subscription_periods_granted": granted,
+ "subscriptions_expired": expired_subscriptions,
+ "credit_balances_archived": expired,
+ "failed_count": len(errors),
+ "errors": errors[:50],
+ }
+ log_operation_event(
+ domain="billing",
+ module="credit_maintenance",
+ event_type="CREDIT_MAINTENANCE_COMPLETED",
+ event_status="failed" if errors else "success",
+ source="app.tasks.credit_tasks._run_credit_maintenance_once",
+ message="积分维护批次完成",
+ detail=result,
+ error=(f"{len(errors)} 条处理失败" if errors else None),
+ )
+ return result
+
+
+if celery_app:
+
+ @celery_app.task(
+ name="credit.maintenance_once",
+ bind=True,
+ soft_time_limit=540,
+ time_limit=600,
+ ignore_result=True,
+ )
+ def credit_maintenance_once(self, batch_size: int = 500) -> dict[str, Any]:
+ return run_async(_run_credit_maintenance_once(batch_size))
+
+else:
+
+ class _DisabledTask:
+ def delay(self, *args: Any, **kwargs: Any) -> None:
+ raise RuntimeError("Celery is disabled")
+
+ def apply_async(self, *args: Any, **kwargs: Any) -> None:
+ raise RuntimeError("Celery is disabled")
+
+ credit_maintenance_once = _DisabledTask()
diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py
index ab7372dd..3d060b7d 100644
--- a/video-gen-api/app/tasks/shot_replicate_tasks.py
+++ b/video-gen-api/app/tasks/shot_replicate_tasks.py
@@ -9,7 +9,6 @@ from sqlalchemy import select, update
from app.config import settings
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule, CreditRecordSourceStepCode
-from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.celery_queue import CeleryQueue, CeleryTaskName
from app.enums.celery_runtime import CeleryRuntimeDomain
from app.enums.shot_replicate import (
@@ -40,19 +39,19 @@ from app.services.shot_replicate_taskset_service import (
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
from app.services.llm_billing import (
LlmBillingContext,
- ensure_hold_exists,
+ ensure_pre_deducted,
log_provider_failure,
log_provider_start,
log_provider_success,
- release_on_failure,
- settle_success,
+ mark_business_success,
+ record_provider_exception,
+ finalize_llm_business_failure,
)
from app.services.shot_video_split_service import cleanup_split_result, finalize_split_result, split_video_segment_async
from app.services.upload_video_asset_service import validate_split_range
from app.services.upload_resource import record_shot_segment_upload_resource
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
-from app.utils.exceptions import InsufficientCreditsError
logger = logging.getLogger("video_gen")
@@ -170,7 +169,7 @@ async def _persist_original_analysis_result(
billing_context: LlmBillingContext,
allow_business_write: bool,
) -> str:
- """持久化原视频分析结果并完成账务;供应商成功后禁止再次调用模型。"""
+ """持久化原视频分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
async with async_session() as db:
result = await db.execute(
select(ShotReplicateTaskSet)
@@ -190,32 +189,35 @@ async def _persist_original_analysis_result(
and str(task_set.video_url) == str(video_url)
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
)
- if is_current and task_set is not None:
- result_json = analyzed.result
- task_set.original_video_content = str(result_json.get("原视频内容") or "无")
- task_set.original_video_category = str(result_json.get("原视频分类") or "无")
- task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
- task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
- task_set.analysis_raw_json = analyzed.raw_response
- task_set.analysis_result_json = result_json
- task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
- task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
- task_set.analysis_claim_token = None
- task_set.analysis_lease_until = None
- task_set.analysis_error_message = None
- description = "拆镜复刻-原视频分析"
- outcome = "completed"
- else:
- description = "拆镜复刻-原视频分析(失效结果结算)"
- outcome = "stale_settled"
- await settle_success(
+ if not is_current or task_set is None:
+ await db.rollback()
+ await log_provider_failure(db, billing_context, error="原视频分析业务 attempt 已失效,供应商结果被丢弃")
+ await finalize_llm_business_failure(
+ billing_context,
+ error="原视频分析业务 attempt 已失效,供应商结果被丢弃",
+ )
+ return "stale_refunded"
+
+ result_json = analyzed.result
+ task_set.original_video_content = str(result_json.get("原视频内容") or "无")
+ task_set.original_video_category = str(result_json.get("原视频分类") or "无")
+ task_set.original_video_audience = str(result_json.get("原视频受众人群") or "无")
+ task_set.ai_suggestion_json = result_json.get("拆镜内容剖析") or []
+ task_set.analysis_raw_json = analyzed.raw_response
+ task_set.analysis_result_json = result_json
+ task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
+ task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
+ task_set.analysis_claim_token = None
+ task_set.analysis_lease_until = None
+ task_set.analysis_error_message = None
+ await mark_business_success(
db,
billing_context,
usage=analyzed.usage,
- description=description,
+ description="拆镜复刻-原视频分析",
)
await db.commit()
- return outcome
+ return "completed"
async def _persist_segment_analysis_result(
@@ -228,7 +230,7 @@ async def _persist_segment_analysis_result(
billing_context: LlmBillingContext,
allow_business_write: bool,
) -> str:
- """持久化自定义切片分析结果并完成账务;供应商成功后禁止再次调用模型。"""
+ """持久化切片分析结果;失效结果按最终失败退款,禁止错误结算成功。"""
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
@@ -248,103 +250,102 @@ async def _persist_segment_analysis_result(
and str(segment.segment_video_url) == str(video_url)
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
)
- if is_current and segment is not None:
- result_json = analyzed.result
- segment.original_video_content = str(result_json.get("原视频内容") or "无")
- segment.original_video_category = str(result_json.get("原视频分类") or "无")
- segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
- segment.segment_content = segment.original_video_content
- segment.segment_category = segment.original_video_category
- segment.segment_audience = segment.original_video_audience
- segment.analysis_json = result_json
- segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
- segment.analysis_claim_token = None
- segment.analysis_lease_until = None
- segment.analysis_error_message = None
- description = "拆镜复刻-片段视频分析"
- outcome = "completed"
- else:
- description = "拆镜复刻-片段视频分析(失效结果结算)"
- outcome = "stale_settled"
- await settle_success(
+ if not is_current or segment is None:
+ await db.rollback()
+ await log_provider_failure(db, billing_context, error="片段视频分析业务 attempt 已失效,供应商结果被丢弃")
+ await finalize_llm_business_failure(
+ billing_context,
+ error="片段视频分析业务 attempt 已失效,供应商结果被丢弃",
+ )
+ return "stale_refunded"
+
+ result_json = analyzed.result
+ segment.original_video_content = str(result_json.get("原视频内容") or "无")
+ segment.original_video_category = str(result_json.get("原视频分类") or "无")
+ segment.original_video_audience = str(result_json.get("原视频受众人群") or "无")
+ segment.segment_content = segment.original_video_content
+ segment.segment_category = segment.original_video_category
+ segment.segment_audience = segment.original_video_audience
+ segment.analysis_json = result_json
+ segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
+ segment.analysis_claim_token = None
+ segment.analysis_lease_until = None
+ segment.analysis_error_message = None
+ await mark_business_success(
db,
billing_context,
usage=analyzed.usage,
- description=description,
+ description="拆镜复刻-片段视频分析",
)
await db.commit()
- return outcome
+ return "completed"
-async def _mark_original_provider_success_pending_manual(
+async def _finalize_original_analysis_failure(
*,
task_set_id: str,
attempt_no: int,
token: str,
- error_message: str,
-) -> bool:
- """供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
+ billing_context: LlmBillingContext,
+ error: str,
+) -> None:
async with async_session() as db:
result = await db.execute(
select(ShotReplicateTaskSet)
- .where(
- ShotReplicateTaskSet.id == task_set_id,
- ShotReplicateTaskSet.deleted_at.is_(None),
- )
+ .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
task_set = result.scalar_one_or_none()
- if not (
+ if (
task_set
and int(task_set.analysis_attempt_no or 1) == attempt_no
and task_set.analysis_claim_token == token
and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
):
- await db.rollback()
- return False
- task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
- task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
- task_set.analysis_claim_token = None
- task_set.analysis_lease_until = None
- task_set.analysis_error_message = error_message
+ task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
+ task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
+ task_set.analysis_claim_token = None
+ task_set.analysis_lease_until = None
+ task_set.analysis_error_message = str(error)[:1000]
await db.commit()
- return True
+ await finalize_llm_business_failure(billing_context, error=error)
-async def _mark_segment_provider_success_pending_manual(
+async def _finalize_segment_analysis_failure(
*,
segment_id: str,
attempt_no: int,
token: str,
- error_message: str,
-) -> bool:
- """供应商已成功但本地结算失败:终止自动恢复,保留 HOLD 等待人工对账。"""
+ billing_context: LlmBillingContext,
+ error: str,
+) -> tuple[str | None, str | None]:
+ user_id: str | None = None
+ task_set_id: str | None = None
async with async_session() as db:
result = await db.execute(
select(ShotReplicateSegment)
- .where(
- ShotReplicateSegment.id == segment_id,
- ShotReplicateSegment.deleted_at.is_(None),
- )
+ .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
segment = result.scalar_one_or_none()
- if not (
+ if segment:
+ user_id = str(segment.user_id)
+ task_set_id = str(segment.task_set_id)
+ if (
segment
and int(segment.analysis_attempt_no or 1) == attempt_no
and segment.analysis_claim_token == token
and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
):
- await db.rollback()
- return False
- segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
- segment.analysis_claim_token = None
- segment.analysis_lease_until = None
- segment.analysis_error_message = error_message
+ segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
+ segment.analysis_claim_token = None
+ segment.analysis_lease_until = None
+ segment.analysis_error_message = str(error)[:1000]
await db.commit()
- return True
+ await finalize_llm_business_failure(billing_context, error=error)
+ return user_id, task_set_id
async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int | None) -> None:
@@ -470,13 +471,12 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
source_step_id=task_set_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=task_set_id,
- hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- description_prefix="拆镜复刻原视频分析",
+ description_prefix="拆镜复刻原视频分析",
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
- error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止原视频分析任务"
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
+ error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务"
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
task_set.analysis_claim_token = None
@@ -511,7 +511,7 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
provider_succeeded = False
analyzed = None
- log_provider_start(
+ await log_provider_start(db,
llm_billing_context,
detail={"analysis_mode": "full_breakdown", "video_url": video_url},
)
@@ -523,9 +523,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
mode="full_breakdown",
task_set_id=task_set_id,
trace_id=f"shot-task-set-analysis:{task_set_id}:attempt:{attempt_no}",
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=analyzed.usage)
+ await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
try:
await lease.ensure_owned()
allow_business_write = True
@@ -566,8 +568,11 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
except RedisExecutionLockError:
raise
except Exception as exc:
- if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
- log_provider_failure(llm_billing_context, error=str(exc))
+ if "llm_billing_context" in locals():
+ if locals().get("provider_succeeded", False):
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
try:
try:
@@ -595,40 +600,16 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
)
return
except Exception as settlement_exc:
- manual_error = (
- "供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
- f"需人工对账。error={settlement_exc}"
- )
- await _mark_original_provider_success_pending_manual(
- task_set_id=task_set_id,
- attempt_no=attempt_no,
- token=token,
- error_message=manual_error,
- )
+ await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
exc = settlement_exc
- elif "llm_billing_context" in locals():
- async with async_session() as db:
- result = await db.execute(
- select(ShotReplicateTaskSet)
- .where(ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None))
- .with_for_update()
- .limit(1)
- )
- task_set = result.scalar_one_or_none()
- if (
- task_set
- and int(task_set.analysis_attempt_no or 1) == attempt_no
- and task_set.analysis_claim_token == token
- and task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value
- ):
- task_set_user_id = task_set_user_id or str(task_set.user_id)
- task_set.status = ShotTaskSetStatusEnum.ANALYSIS_FAILED.value
- task_set.analysis_status = ShotAnalysisStatusEnum.FAILED.value
- task_set.analysis_claim_token = None
- task_set.analysis_lease_until = None
- task_set.analysis_error_message = str(exc)
- await release_on_failure(db, llm_billing_context, error=str(exc))
- await db.commit()
+ if "llm_billing_context" in locals():
+ await _finalize_original_analysis_failure(
+ task_set_id=task_set_id,
+ attempt_no=attempt_no,
+ token=token,
+ billing_context=llm_billing_context,
+ error=str(exc),
+ )
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.ANALYSIS_FAILED.value,
@@ -762,13 +743,12 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
source_step_id=segment_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=segment_id,
- hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
- description_prefix="拆镜复刻片段视频分析",
+ description_prefix="拆镜复刻片段视频分析",
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
)
- hold_validation = await ensure_hold_exists(db, llm_billing_context)
- if not hold_validation.can_execute:
- error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止片段视频分析任务"
+ pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context)
+ if not pre_deduct_validation.can_execute:
+ error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务"
segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
segment.analysis_claim_token = None
segment.analysis_lease_until = None
@@ -798,7 +778,7 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
provider_succeeded = False
analyzed = None
- log_provider_start(
+ await log_provider_start(db,
llm_billing_context,
detail={"analysis_mode": "summary_only", "video_url": video_url},
)
@@ -811,9 +791,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
task_set_id=task_set_id,
segment_id=segment_id,
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
+ fixed_model_config_id=llm_billing_context.model_config_id,
+ fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
- log_provider_success(llm_billing_context, usage=analyzed.usage)
+ await log_provider_success(db, llm_billing_context, usage=analyzed.usage)
try:
await lease.ensure_owned()
allow_business_write = True
@@ -854,8 +836,11 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
except RedisExecutionLockError:
raise
except Exception as exc:
- if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
- log_provider_failure(llm_billing_context, error=str(exc))
+ if "llm_billing_context" in locals():
+ if locals().get("provider_succeeded", False):
+ await log_provider_failure(db, llm_billing_context, error=str(exc))
+ else:
+ provider_succeeded, _usage = await record_provider_exception(db, llm_billing_context, exc)
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False) and locals().get("analyzed") is not None:
try:
try:
@@ -884,40 +869,18 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
)
return
except Exception as settlement_exc:
- manual_error = (
- "供应商已成功,但本地结果保存或积分结算失败;已终止自动恢复并保留冻结积分,"
- f"需人工对账。error={settlement_exc}"
- )
- await _mark_segment_provider_success_pending_manual(
- segment_id=segment_id,
- attempt_no=attempt_no,
- token=token,
- error_message=manual_error,
- )
+ await log_provider_failure(db, llm_billing_context, error=str(settlement_exc))
exc = settlement_exc
- elif "llm_billing_context" in locals():
- async with async_session() as db:
- result = await db.execute(
- select(ShotReplicateSegment)
- .where(ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None))
- .with_for_update()
- .limit(1)
- )
- segment = result.scalar_one_or_none()
- if (
- segment
- and int(segment.analysis_attempt_no or 1) == attempt_no
- and segment.analysis_claim_token == token
- and segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value
- ):
- user_id = user_id or str(segment.user_id)
- task_set_id = task_set_id or str(segment.task_set_id)
- segment.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
- segment.analysis_claim_token = None
- segment.analysis_lease_until = None
- segment.analysis_error_message = str(exc)
- await release_on_failure(db, llm_billing_context, error=str(exc))
- await db.commit()
+ if "llm_billing_context" in locals():
+ found_user_id, found_task_set_id = await _finalize_segment_analysis_failure(
+ segment_id=segment_id,
+ attempt_no=attempt_no,
+ token=token,
+ billing_context=llm_billing_context,
+ error=str(exc),
+ )
+ user_id = user_id or found_user_id
+ task_set_id = task_set_id or found_task_set_id
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_ANALYSIS_FAILED.value,
@@ -984,20 +947,17 @@ async def _mark_auto_segment_analysis_dispatch_failed(
segment.analysis_started_at = None
segment.analysis_lease_until = None
segment.analysis_error_message = error_message
- await release_on_failure(
- db,
- build_segment_analysis_billing_context(segment),
- error=error_message,
- )
+ billing_context = build_segment_analysis_billing_context(segment)
await db.commit()
+ await finalize_llm_business_failure(billing_context, error=error_message)
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=task_set_id,
step_id=segment_id,
user_id=user_id,
- message="自定义切片分析任务投递失败,已标记分析失败并释放冻结积分",
+ message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分",
detail={
"segment_id": segment_id,
"task_set_id": task_set_id,
@@ -1256,7 +1216,7 @@ async def _run_split_one_segment(segment_id: str) -> None:
segment.split_status = ShotSplitStatusEnum.FAILED.value
segment.split_next_retry_at = None
final_failed = True
- # 切片最终失败不释放片段分析 HOLD;手动切片重试继续沿用
+ # 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用
# 原 attempt。用户最终删除片段/任务集时再做取消补偿。
else:
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts
index 4f7d81fb..1a9a9246 100644
--- a/video-gen-app/src/api/index.ts
+++ b/video-gen-app/src/api/index.ts
@@ -280,10 +280,29 @@ export async function retryGeneration(recordId: string): Promise(`/generation-records/${recordId}/retry`);
}
// ── Credits ───────────────────────────────────────────────
-export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> {
+export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; availableCredits: number; nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null; totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number }> {
if (USE_MOCK) {
const data = await mock.mockGetCredits();
- return data;
+ const records = data.records || [];
+ const totalGranted = records
+ .filter((record) => record.type === 'recharge' || record.type === 'refund')
+ .reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0);
+ const totalConsumed = records
+ .filter((record) => record.type === 'consume')
+ .reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0);
+ return {
+ credits: data.credits,
+ availableCredits: data.credits,
+ nextExpiringCredits: 0,
+ nextExpiresAt: null,
+ nextLastUsableAt: null,
+ totalGranted,
+ totalConsumed,
+ totalRefunded: 0,
+ totalExpired: 0,
+ records,
+ total: data.total,
+ };
}
const params = new URLSearchParams();
params.set('page', String(page));
@@ -301,7 +320,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise {
+export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> {
if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' };
return api.get('/auth/site-info', false);
}
@@ -542,7 +561,7 @@ export async function getone(projectId: string, stepId: string): Promise {
export async function updateImagePrompt(projectId: string, stepId: string, params: any): Promise {
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/image-prompt`, params);
}
-
+
// 第二步,生成图片
export async function gettwo(projectId: string, stepId: string ,params: any): Promise {
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params);
@@ -915,7 +934,7 @@ export async function deleteOAuthAccount(params: DeleteOAuthAccountParams): Prom
export async function getAllOAuthAccountList(): Promise {
return api.post(`/upload-material/oauth_account_list`);
}
-// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
+// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
export async function submitPreTest(params: { resources_material_ids: any[]; pre_test_template_id: string }): Promise {
return api.post(`/resources-material/pre-commit`, params);
}
@@ -1263,3 +1282,14 @@ export function getTeamCreditExportUrl(params: {
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
return `${base}/api/team/credit-records/export?${p.toString()}`;
}
+
+// ── Dynamic credit products ───────────────────────────────
+export async function getCreditProductCatalog(): Promise {
+ return api.get('/credit-products/catalog');
+}
+
+export async function getCreditBalances(page = 1, pageSize = 20, status?: string): Promise {
+ const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
+ if (status) params.set('status', status);
+ return api.get(`/credits/balances?${params.toString()}`);
+}
diff --git a/video-gen-app/src/api/mock.ts b/video-gen-app/src/api/mock.ts
index 5a2ed396..5c479178 100644
--- a/video-gen-app/src/api/mock.ts
+++ b/video-gen-app/src/api/mock.ts
@@ -207,10 +207,14 @@ export async function mockOptimizePrompt(
await delay(1500);
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
- const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2);
+ // Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。
+ const cost = 5;
if (currentUser) {
- currentUser.credits -= cost;
+ if (currentUser.credits < cost) {
+ throw new Error('积分不足');
+ }
+ currentUser.credits = Math.round((currentUser.credits - cost) * 100) / 100;
}
const optimizedPromptMap: Record = {
@@ -356,7 +360,11 @@ export async function mockGetAdminUsers(search?: string): Promise {
export async function mockAdjustCredits(userId: string, amount: number, _description: string): Promise {
await delay(500);
const user = MOCK_ADMIN_USERS.find(u => u.id === userId);
- if (user) user.credits += amount;
+ if (user) {
+ const nextCredits = Math.round((user.credits + amount) * 100) / 100;
+ if (nextCredits < 0) throw new Error('有效积分不足');
+ user.credits = nextCredits;
+ }
}
export async function mockToggleUserStatus(userId: string, isActive: boolean): Promise {
diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx
index 814a442b..3972b095 100644
--- a/video-gen-app/src/components/Layout/AppLayout.tsx
+++ b/video-gen-app/src/components/Layout/AppLayout.tsx
@@ -65,7 +65,8 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
-import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
+import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
+import type { CreditProduct, CreditProductCatalog } from '../../types';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
import bg1 from '../../assets/bg1.png';
@@ -319,79 +320,33 @@ const MEMBERSHIP_GRADIENTS = [
{ gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: },
];
-const MEMBERSHIP_FEATURES = [
- '每日赠送积分 当日清零',
- '可充值更多积分',
- '基础加速通道',
- '作品去除水印',
- '可使用网页版和APP上全部AI功能',
- '高分辨率',
- '批量创作 更多同时生成任务',
-];
-
-const MEMBERSHIP_PLANS = [
- {
- tier: '基础会员',
- desc: '开启创作之旅',
- monthlyPrice: 79,
- monthlyCredits: 1000,
- onlyMonthly: true,
- features: MEMBERSHIP_FEATURES.map((f) => f.replace('基础加速通道', '基础加速通道')),
- speedLabel: '基础加速通道',
- },
- {
- tier: '标准会员',
- desc: '畅享基础创作权益',
- monthlyPrice: 154,
- monthlyCredits: 2000,
- quarterlyPrice: 1029,
- quarterlyCredits: 5000,
- yearlyPrice: 7308,
- yearlyCredits: 10000,
- features: MEMBERSHIP_FEATURES,
- speedLabel: '标准加速通道',
- },
- {
- tier: '高级会员',
- desc: '解锁高级创作权益',
- monthlyPrice: 221,
- monthlyCredits: 3000,
- quarterlyPrice: 1197,
- quarterlyCredits: 6000,
- yearlyPrice: 10710,
- yearlyCredits: 15000,
- features: MEMBERSHIP_FEATURES,
- speedLabel: '高级加速通道',
- },
- {
- tier: '超级会员',
- desc: '释放无限创作生产力',
- monthlyPrice: 280,
- monthlyCredits: 4000,
- quarterlyPrice: 1367,
- quarterlyCredits: 7000,
- yearlyPrice: 13440,
- yearlyCredits: 20000,
- features: MEMBERSHIP_FEATURES,
- speedLabel: '高级加速通道',
- hot: true,
- },
-];
-
const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
- const { user, logout, refreshUser, setOptimizeHoldCredits } = useAuthStore();
+ const { user, logout, refreshUser } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [pwdForm] = Form.useForm();
const [usernameForm] = Form.useForm();
- const [selectedPlan, setSelectedPlan] = useState(null);
+ const [selectedPlan, setSelectedPlan] = useState(null);
const [menuItems, setMenuItems] = useState([]);
- const [rechargeOptions, setRechargeOptions] = useState([]);
+ const [rechargeOptions, setRechargeOptions] = useState([]);
+ const [subscriptionProducts, setSubscriptionProducts] = useState([]);
+ const [currentSubscription, setCurrentSubscription] = useState(null);
+ const loadCreditCatalog = useCallback(async () => {
+ try {
+ const data = await getCreditProductCatalog();
+ setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
+ setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
+ setCurrentSubscription(data.currentSubscription || null);
+ } catch {
+ setSubscriptionProducts([]);
+ setRechargeOptions([]);
+ setCurrentSubscription(null);
+ }
+ }, []);
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
- const [selectedTierIndex, setSelectedTierIndex] = useState(null);
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
const [unreadCount, setUnreadCount] = useState(0);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@@ -483,10 +438,6 @@ const AppLayout: React.FC = () => {
setOperationManualUrl(info.operationManual);
}
- if (info.optimizeHoldCredits !== undefined) {
- setOptimizeHoldCredits(info.optimizeHoldCredits);
- } else {
- }
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
}).catch((err) => {
@@ -625,15 +576,13 @@ const AppLayout: React.FC = () => {
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
setMenuItems(items);
}).catch(() => { });
- getRechargePackages().then(data => {
- setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
- }).catch(() => { });
+ loadCreditCatalog();
getPaymentMethods().then(data => {
setEnabledMethods(data);
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { });
- }, []);
+ }, [loadCreditCatalog]);
useEffect(() => {
if (user) {
@@ -817,6 +766,7 @@ const AppLayout: React.FC = () => {
localStorage.removeItem(PENDING_ORDER_KEY);
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
+ loadCreditCatalog();
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
@@ -849,7 +799,7 @@ const AppLayout: React.FC = () => {
});
}, 1000);
countdownTimerRef.current = countdownTimer;
- }, [stopPolling]);
+ }, [loadCreditCatalog, stopPolling]);
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
@@ -1077,22 +1027,22 @@ const AppLayout: React.FC = () => {