积分冻结释放

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