积分冻结释放
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+772
@@ -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)
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 提词任务已提交",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: "消费",
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user