积分消耗明细改版V1

This commit is contained in:
2026-08-12 11:49:53 +08:00
parent e3719eae09
commit eb7c60684e
39 changed files with 648 additions and 975 deletions
+1 -1
View File
@@ -1328,7 +1328,7 @@ export async function adminDeductCredits(userId: string, payload: Record<string,
return api.post(`/admin/credit-management/users/${userId}/deduct`, payload); return api.post(`/admin/credit-management/users/${userId}/deduct`, payload);
} }
// ── LLM Fixed Pre-deduct ────────────────────────────────── // ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> { export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies'); return api.get('/admin/llm-billing/policies');
} }
@@ -47,10 +47,11 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = { const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实费', color: 'red' }, charge: { text: '真实费', color: 'red' },
refund: { text: '真实退款', color: 'blue' }, refund: { text: '真实退款', color: 'blue' },
hold: { text: '预扣占用', color: 'gold' }, pre_deduct: { text: '历史固定预扣', color: 'default' },
hold_release: { text: '预扣释放', color: 'green' }, hold: { text: '历史预扣占用', color: 'gold' },
hold_release: { text: '历史预扣释放', color: 'green' },
}; };
const userScopeOptions = [ const userScopeOptions = [
@@ -105,10 +106,11 @@ const chargeKindOptions = [
const chargeActionOptions = [ const chargeActionOptions = [
{ value: '', label: '全部交易动作' }, { value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实费' }, { value: 'charge', label: '真实费' },
{ value: 'refund', label: '真实退款' }, { value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' }, { value: 'pre_deduct', label: '历史固定预扣' },
{ value: 'hold_release', label: '预扣释放' }, { value: 'hold', label: '历史预扣占用' },
{ value: 'hold_release', label: '历史预扣释放' },
]; ];
const sourceModuleOptions = [ const sourceModuleOptions = [
@@ -306,7 +308,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' }, { title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' }, { title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' }, { title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') }, { title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceTypeLabel || a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' }, { title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
]; ];
@@ -322,22 +324,18 @@ const AdminCreditRecords: React.FC = () => {
], ],
summaryRows: [ summaryRows: [
['总充值', exportSummary.totalRecharge], ['总充值', exportSummary.totalRecharge],
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume], ['总真实消费', exportSummary.totalConsume],
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge], ['总真实退款', exportSummary.totalRefund],
[' · 预扣占用(独立统计:type=消费 & action=hold', exportSummary.totalHold], ['净消耗(总消费 总退款,≥ 0', exportSummary.netConsume],
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
[' · 真实退款(独立统计:type=回退 & action=refund/NULL', exportSummary.totalRefundReal],
[' · 预扣释放(独立统计:type=回退 & action=hold_release', exportSummary.totalHoldRelease],
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount], ['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount], ['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount], ['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount], ['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount], ['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分(仅真实费)', exportSummary.imageConsume], ['图片消费积分(仅真实费)', exportSummary.imageConsume],
['视频消费积分(仅真实费)', exportSummary.videoConsume], ['视频消费积分(仅真实费)', exportSummary.videoConsume],
['提词消费积分(仅真实费)', exportSummary.textConsume], ['提词消费积分(仅真实费)', exportSummary.textConsume],
['视频分析积分(仅真实费)', exportSummary.analysisConsume], ['视频分析积分(仅真实费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens], ['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens], ['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens], ['输出 Token', exportSummary.outputTokens],
@@ -389,32 +387,18 @@ const AdminCreditRecords: React.FC = () => {
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /> <Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}> <div style={{ color: '#94a3b8' }}></div>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}> + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div> <div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}> <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}></div>
<span style={{ color: '#b91c1c', fontWeight: 600 }}>{n(summary.totalCharge)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#d97706', fontWeight: 600 }}>{n(summary.totalHold)}</span>
</div>
</div> </div>
</Space> </Space>
</Card> </Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}> <Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /> <Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}> <div style={{ color: '#94a3b8' }}>退</div>
退
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>退 + </span>
</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div> <div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}> <div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>退</div>
退 <span style={{ color: '#1d4ed8', fontWeight: 600 }}>{n(summary.totalRefundReal)}</span>
<span style={{ margin: '0 4px', color: '#cbd5e1' }}>|</span>
<span style={{ color: '#047857', fontWeight: 600 }}>{n(summary.totalHoldRelease)}</span>
</div>
</div> </div>
</Space> </Space>
</Card> </Card>
@@ -423,7 +407,7 @@ const AdminCreditRecords: React.FC = () => {
<div> <div>
<div style={{ color: '#6366f1' }}></div> <div style={{ color: '#6366f1' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div> <div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div> <div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}> 退 0</div>
</div> </div>
</Space> </Space>
</Card> </Card>
@@ -498,8 +482,8 @@ const AdminCreditRecords: React.FC = () => {
rowKey="id" rowKey="id"
dataSource={record.allocations || []} dataSource={record.allocations || []}
columns={[ columns={[
{ title: '动作', dataIndex: 'allocationAction', width: 150 }, { title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' },
{ title: '积分来源', dataIndex: 'sourceType', width: 160, render: (v: string) => v || '-' }, { title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' }, { title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) }, { title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
{ title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' }, { title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
@@ -6,7 +6,7 @@ import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate'; import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = { const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pre_deducted: { label: '已预扣', color: 'blue' }, charged: { label: '已消费', color: 'blue' },
processing: { label: '处理中', color: 'processing' }, processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' }, succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' }, final_failed: { label: '最终失败', color: 'error' },
@@ -41,7 +41,7 @@ const AdminLlmBillingExecutions: React.FC = () => {
}))} /> }))} />
<Descriptions bordered size="small" column={4} items={[ <Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot }, { key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '固定预扣', children: record.preDeductCredits }, { key: 'credit', label: '消费积分', children: record.chargeCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` }, { key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens }, { key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits }, { key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
@@ -53,7 +53,7 @@ const AdminLlmBillingExecutions: React.FC = () => {
{ title: '用户', dataIndex: 'userId' }, { title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` }, { title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' }, { title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '预扣', dataIndex: 'preDeductCredits' }, { title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` }, { title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } }, { title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) }, { title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
@@ -20,32 +20,64 @@ const AdminLlmBillingPolicies: React.FC = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const load = async () => { setLoading(true); try { setItems(await getLlmBillingPolicies()); } finally { setLoading(false); } };
const load = async () => {
setLoading(true);
try {
setItems(await getLlmBillingPolicies());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => { setEditing(item || null); setOpen(true); form.resetFields(); form.setFieldsValue(item ? { scene_code: item.sceneCode, pre_deduct_credits: item.preDeductCredits, is_active: item.isActive } : { is_active: true, pre_deduct_credits: 5 }); };
const edit = (item?: LlmBillingPolicy) => {
setEditing(item || null);
setOpen(true);
form.resetFields();
form.setFieldsValue(item
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
: { is_active: true, charge_credits: 5 });
};
const save = async () => { const save = async () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values); else await createLlmBillingPolicy(values); if (editing) await updateLlmBillingPolicy(editing.id, values);
message.success('已保存'); setOpen(false); load(); else await createLlmBillingPolicy(values);
} catch (error: any) { if (!error?.errorFields) message.error(error?.message || '保存失败'); } message.success('保存');
setOpen(false);
load();
} catch (error: any) {
if (!error?.errorFields) message.error(error?.message || '保存失败');
}
}; };
return <Card title={<Space><RobotOutlined />LLM固定预扣配置</Space>} extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}>
return <Card
title={<Space><RobotOutlined />LLM积分配置</Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}
>
<Table rowKey="id" loading={loading} dataSource={items} columns={[ <Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' }, { title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' }, { title: '场景编码', dataIndex: 'sceneCode' },
{ title: '固定预扣积分', dataIndex: 'preDeductCredits' }, { title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '版本', dataIndex: 'version' }, { title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> }, { title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> }, { title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> },
]} /> ]} />
<Modal open={open} title={editing ? '编辑LLM场景预扣' : '新增LLM场景预扣'} onOk={save} onCancel={() => setOpen(false)}> <Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}><Select disabled={!!editing} options={SCENES} /></Form.Item> <Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
<Form.Item name="pre_deduct_credits" label="固定预扣积分" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item> <Select disabled={!!editing} options={SCENES} />
</Form.Item>
<Form.Item name="charge_credits" label="消费积分" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item> <Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form> </Form>
</Modal> </Modal>
</Card>; </Card>;
}; };
export default AdminLlmBillingPolicies; export default AdminLlmBillingPolicies;
@@ -63,31 +63,6 @@ const AdminSettings: React.FC = () => {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
// 仅当 llm_billing_enabled 字段在当前标签页渲染时,才校验预扣积分
const llmBillingEnabled = values.llm_billing_enabled !== undefined && values.llm_billing_enabled !== null
? !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled).trim().toLowerCase(),
)
: false;
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);
for (const config of configs) { for (const config of configs) {
const newVal = values[config.key]; const newVal = values[config.key];
+3 -3
View File
@@ -816,9 +816,9 @@ const AdminUsers: React.FC = () => {
pagination={false} pagination={false}
dataSource={creditBalances} dataSource={creditBalances}
columns={[ columns={[
{ title: '来源', dataIndex: 'sourceType', width: 150, render: (v: string) => v || '-' }, { title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' }, { title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' }, { title: '积分等级', dataIndex: 'creditLevelLabel', width: 100, render: (v: string, r: any) => v || r.creditLevel || '-' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
@@ -826,7 +826,7 @@ const AdminUsers: React.FC = () => {
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() }, { title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) }, { title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) }, { title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{v}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || v || '-'}</Tag> },
]} ]}
scroll={{ x: 1410, y: 460 }} scroll={{ x: 1410, y: 460 }}
/> />
+15 -12
View File
@@ -868,17 +868,17 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary { export interface AdminCreditRecordSummary {
totalRecharge: number; totalRecharge: number;
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */ /** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */
totalConsume: number; totalConsume: number;
/** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */ /** 总退款:仅真实退款,不含历史 hold_release */
totalRefund: number; totalRefund: number;
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */ /** 兼容字段:与 totalConsume 同真实消费口径 */
totalCharge: number; totalCharge: number;
/** 独立统计列:预扣占用 hold */ /** 历史兼容字段:hold 占用,不计入 totalConsume */
totalHold: number; totalHold: number;
/** 独立统计列:真实退款 refund(含历史 NULL) */ /** 兼容字段:与 totalRefund 同真实退款口径 */
totalRefundReal: number; totalRefundReal: number;
/** 独立统计列:预扣释放 hold_releasetype=refund, action=hold_release */ /** 历史兼容字段:hold_release,不计入 totalRefund */
totalHoldRelease: number; totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */ /** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number; netConsume: number;
@@ -887,13 +887,13 @@ export interface AdminCreditRecordSummary {
generationAttemptCount: number; generationAttemptCount: number;
imageGenerationCount: number; imageGenerationCount: number;
videoGenerationCount: number; videoGenerationCount: number;
/** 子分类消费(图片)仅真实费 charge 口径 */ /** 子分类消费(图片)仅真实费 charge 口径 */
imageConsume: number; imageConsume: number;
/** 子分类消费(视频)仅真实费 charge 口径 */ /** 子分类消费(视频)仅真实费 charge 口径 */
videoConsume: number; videoConsume: number;
/** 子分类消费(提词)仅真实费 charge 口径 */ /** 子分类消费(提词)仅真实费 charge 口径 */
textConsume: number; textConsume: number;
/** 子分类消费(分析)仅真实费 charge 口径 */ /** 子分类消费(分析)仅真实费 charge 口径 */
analysisConsume: number; analysisConsume: number;
totalTokens: number; totalTokens: number;
inputTokens: number; inputTokens: number;
@@ -905,9 +905,12 @@ export interface AdminCreditRecordAllocation {
creditBalanceId: string; creditBalanceId: string;
sourceAllocationId?: string | null; sourceAllocationId?: string | null;
allocationAction: string; allocationAction: string;
allocationActionLabel?: string;
amount: number; amount: number;
creditLevel?: string; creditLevel?: string;
creditLevelLabel?: string;
sourceType?: string; sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string; sourceId?: string;
validFrom?: string; validFrom?: string;
expiresAt?: string; expiresAt?: string;
@@ -1503,7 +1506,7 @@ export interface LlmBillingPolicy {
id: string; id: string;
sceneCode: string; sceneCode: string;
sceneName: string; sceneName: string;
preDeductCredits: number; chargeCredits: number;
isActive: boolean; isActive: boolean;
version: number; version: number;
createdBy?: string | null; createdBy?: string | null;
@@ -1545,7 +1548,7 @@ export interface LlmBillingExecution {
modelNameSnapshot?: string | null; modelNameSnapshot?: string | null;
providerSnapshot?: string | null; providerSnapshot?: string | null;
requestTime: string; requestTime: string;
preDeductCredits: number; chargeCredits: number;
status: string; status: string;
totalCallCount: number; totalCallCount: number;
successfulCallCount: number; successfulCallCount: number;
@@ -8,7 +8,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db from app.dependencies import get_admin_user, get_db
from app.enums.credit_balance import CreditBalanceSourceType from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CreditBalanceSourceType,
)
from app.models.credit.balance import UserCreditBalance from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct from app.models.credit.product import CreditProduct
from app.models.user import User from app.models.user import User
@@ -248,7 +253,9 @@ async def list_user_credit_balances(
{ {
"id": item.id, "id": item.id,
"credit_level": item.credit_level, "credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
"source_type": item.source_type, "source_type": item.source_type,
"source_type_label": CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, item.source_type),
"source_id": item.source_id, "source_id": item.source_id,
"grant_amount": float(item.grant_amount), "grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount), "unspent_amount": float(item.unspent_amount),
@@ -258,7 +265,8 @@ async def list_user_credit_balances(
"valid_from": item.valid_from, "valid_from": item.valid_from,
"expires_at": item.expires_at, "expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at), "last_usable_at": last_usable_at(item.expires_at),
"status": effective_balance_status(item, request_time=checked_at), "status": (status_value := effective_balance_status(item, request_time=checked_at)),
"status_label": CREDIT_BALANCE_STATUS_LABELS.get(status_value, status_value),
} }
for item in result.scalars().all() for item in result.scalars().all()
] ]
+71 -12
View File
@@ -16,13 +16,29 @@ from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"]) router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
def _policy_out(policy: LlmBillingPolicyModel) -> dict:
# 数据库 pre_deduct_credits 是历史物理字段名;Admin API 统一输出 charge_credits。
return {
"id": policy.id,
"scene_code": policy.scene_code,
"scene_name": policy.scene_name,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
"version": policy.version,
"created_by": policy.created_by,
"updated_by": policy.updated_by,
"created_at": policy.created_at,
"updated_at": policy.updated_at,
}
@router.get("/policies") @router.get("/policies")
async def list_policies( async def list_policies(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code)) result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
return list(result.scalars().all()) return [_policy_out(item) for item in result.scalars().all()]
@router.post("/policies") @router.post("/policies")
@@ -33,14 +49,20 @@ async def create_policy(
): ):
if data.scene_code not in LLM_BILLING_SCENE_LABELS: if data.scene_code not in LLM_BILLING_SCENE_LABELS:
raise HTTPException(status_code=400, detail="不支持的LLM业务场景") raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
existing = await db.execute(select(LlmBillingPolicyModel.id).where(LlmBillingPolicyModel.scene_code == data.scene_code).limit(1)) existing = await db.execute(
select(LlmBillingPolicyModel.id)
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
.limit(1)
)
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该LLM业务场景已存在") raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
policy = LlmBillingPolicyModel( policy = LlmBillingPolicyModel(
id=generate_id(), id=generate_id(),
scene_code=data.scene_code, scene_code=data.scene_code,
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code], scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
pre_deduct_credits=data.pre_deduct_credits, # 保留历史物理字段,不做无业务价值的数据库重命名迁移。
pre_deduct_credits=data.charge_credits,
is_active=data.is_active, is_active=data.is_active,
version=1, version=1,
created_by=admin.id, created_by=admin.id,
@@ -48,8 +70,17 @@ async def create_policy(
) )
db.add(policy) db.add(policy)
await db.flush() await db.flush()
log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_CREATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "credits": float(policy.pre_deduct_credits)}) policy_id = policy.id
return policy scene_code = policy.scene_code
charge_credits = float(policy.pre_deduct_credits)
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_CREATED",
user_id=admin.id,
detail={"scene_code": scene_code, "charge_credits": charge_credits, "policy_id": policy_id},
)
return _policy_out(policy)
@router.put("/policies/{policy_id}") @router.put("/policies/{policy_id}")
@@ -59,20 +90,41 @@ async def update_policy(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute(select(LlmBillingPolicyModel).where(LlmBillingPolicyModel.id == policy_id).limit(1).with_for_update()) result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.id == policy_id)
.limit(1)
.with_for_update()
)
policy = result.scalar_one_or_none() policy = result.scalar_one_or_none()
if not policy: if not policy:
raise HTTPException(status_code=404, detail="LLM计费场景不存在") raise HTTPException(status_code=404, detail="LLM积分场景不存在")
payload = data.model_dump(exclude_unset=True) payload = data.model_dump(exclude_unset=True)
payload.pop("scene_name", None) payload.pop("scene_name", None)
for key, value in payload.items(): if "charge_credits" in payload:
setattr(policy, key, value) policy.pre_deduct_credits = payload.pop("charge_credits")
if "is_active" in payload:
policy.is_active = payload["is_active"]
policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name) policy.scene_name = LLM_BILLING_SCENE_LABELS.get(policy.scene_code, policy.scene_name)
policy.version += 1 policy.version += 1
policy.updated_by = admin.id policy.updated_by = admin.id
await db.flush() await db.flush()
log_operation_event(domain="llm_billing", module="admin", event_type="LLM_BILLING_POLICY_UPDATED", user_id=admin.id, detail={"scene_code": policy.scene_code, "version": policy.version})
return policy log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_UPDATED",
user_id=admin.id,
detail={
"scene_code": policy.scene_code,
"policy_id": policy.id,
"version": policy.version,
"charge_credits": float(policy.pre_deduct_credits),
"is_active": policy.is_active,
},
)
return _policy_out(policy)
@router.get("/executions") @router.get("/executions")
@@ -85,5 +137,12 @@ async def list_executions(
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
items, total = await list_executions_with_calls(db, page=page, page_size=page_size, scene_code=scene_code, status=status, user_id=user_id) items, total = await list_executions_with_calls(
db,
page=page,
page_size=page_size,
scene_code=scene_code,
status=status,
user_id=user_id,
)
return {"items": items, "total": total} return {"items": items, "total": total}
+1 -1
View File
@@ -532,7 +532,7 @@ async def create_shot_task_set(
task_set, created_new = 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 = str(task_set.id) task_set_id = str(task_set.id)
if not created_new: if not created_new:
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。 # 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
await db.rollback() await db.rollback()
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)
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1)) analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
+40
View File
@@ -13,6 +13,11 @@ CREDIT_LEVEL_SORT = {
CreditLevel.GENERAL.value: 20, CreditLevel.GENERAL.value: 20,
} }
CREDIT_LEVEL_LABELS = {
CreditLevel.PROMOTIONAL.value: "活动积分",
CreditLevel.GENERAL.value: "普通积分",
}
class CreditBalanceSourceType(StrEnum): class CreditBalanceSourceType(StrEnum):
REGISTER_GIFT = "register_gift" REGISTER_GIFT = "register_gift"
@@ -26,6 +31,19 @@ class CreditBalanceSourceType(StrEnum):
BUSINESS_REFUND = "business_refund" BUSINESS_REFUND = "business_refund"
CREDIT_BALANCE_SOURCE_TYPE_LABELS = {
CreditBalanceSourceType.REGISTER_GIFT.value: "注册赠送",
CreditBalanceSourceType.DAILY_LOGIN.value: "每日登录赠送",
CreditBalanceSourceType.SIGN_IN.value: "签到赠送",
CreditBalanceSourceType.ACTIVITY.value: "活动赠送",
CreditBalanceSourceType.ADMIN_GRANT.value: "管理员赠送",
CreditBalanceSourceType.SUBSCRIPTION_GRANT.value: "订阅积分发放",
CreditBalanceSourceType.CREDIT_ADDON.value: "积分增值包",
CreditBalanceSourceType.LEGACY_MIGRATION.value: "历史积分迁移",
CreditBalanceSourceType.BUSINESS_REFUND.value: "业务退款",
}
class CreditBalanceStatus(StrEnum): class CreditBalanceStatus(StrEnum):
SCHEDULED = "scheduled" SCHEDULED = "scheduled"
ACTIVE = "active" ACTIVE = "active"
@@ -35,6 +53,16 @@ class CreditBalanceStatus(StrEnum):
CANCELLED = "cancelled" CANCELLED = "cancelled"
CREDIT_BALANCE_STATUS_LABELS = {
CreditBalanceStatus.SCHEDULED.value: "待生效",
CreditBalanceStatus.ACTIVE.value: "有效",
CreditBalanceStatus.CONSUMED.value: "已用完",
CreditBalanceStatus.EXPIRED.value: "已过期",
CreditBalanceStatus.REVOKED.value: "已撤销",
CreditBalanceStatus.CANCELLED.value: "已取消",
}
class CreditAllocationAction(StrEnum): class CreditAllocationAction(StrEnum):
GRANT = "grant" GRANT = "grant"
CONSUME = "consume" CONSUME = "consume"
@@ -44,3 +72,15 @@ class CreditAllocationAction(StrEnum):
REVOKE = "revoke" REVOKE = "revoke"
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out" UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in" UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
CREDIT_ALLOCATION_ACTION_LABELS = {
CreditAllocationAction.GRANT.value: "发放",
CreditAllocationAction.CONSUME.value: "消费",
CreditAllocationAction.REFUND_AVAILABLE.value: "有效积分退款",
CreditAllocationAction.REFUND_EXPIRED.value: "过期积分退款",
CreditAllocationAction.EXPIRE.value: "积分过期",
CreditAllocationAction.REVOKE.value: "积分撤销",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value: "升级积分转出",
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value: "升级积分转入",
}
+4 -4
View File
@@ -126,11 +126,11 @@ class CreditRecordBillingScene(str, Enum):
CREDIT_RECORD_ACTION_LABELS = { CREDIT_RECORD_ACTION_LABELS = {
CreditRecordAction.CHARGE.value: "真实", CreditRecordAction.CHARGE.value: "真实",
CreditRecordAction.PRE_DEDUCT.value: "固定预扣", CreditRecordAction.PRE_DEDUCT.value: "历史固定预扣",
CreditRecordAction.REFUND.value: "真实退款", CreditRecordAction.REFUND.value: "真实退款",
CreditRecordAction.HOLD.value: "预扣占用", CreditRecordAction.HOLD.value: "历史预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "预扣释放", CreditRecordAction.HOLD_RELEASE.value: "历史预扣释放",
} }
CREDIT_RECORD_TYPE_LABELS = { CREDIT_RECORD_TYPE_LABELS = {
+8 -7
View File
@@ -12,12 +12,12 @@ class LlmBillingLedgerState(StrEnum):
INVALID = "invalid" INVALID = "invalid"
BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费 BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费
RELEASED = "refunded" # 历史兼容别名 RELEASED = "refunded" # 历史兼容别名
CHARGED = "succeeded" # 历史兼容别名 CHARGED = "active" # 当前 attempt 已完成真实消费,可继续执行
class LlmBillingExecutionStatus(StrEnum): class LlmBillingExecutionStatus(StrEnum):
PRE_DEDUCTED = "pre_deducted" CHARGED = "charged"
PRE_DEDUCTED = "pre_deducted" # 历史兼容;新数据不得再创建
PROCESSING = "processing" PROCESSING = "processing"
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
FINAL_FAILED = "final_failed" FINAL_FAILED = "final_failed"
@@ -34,10 +34,11 @@ class LlmCallAttemptStatus(StrEnum):
class LlmBillingEvent(StrEnum): class LlmBillingEvent(StrEnum):
PRE_DEDUCT_START = "LLM_PRE_DEDUCT_START" CHARGE_START = "LLM_CHARGE_START"
PRE_DEDUCT_SUCCESS = "LLM_PRE_DEDUCT_SUCCESS" CHARGE_IDEMPOTENT_HIT = "LLM_CHARGE_IDEMPOTENT_HIT"
PRE_DEDUCT_INSUFFICIENT = "LLM_PRE_DEDUCT_INSUFFICIENT" CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
PRE_DEDUCT_CONFIG_INVALID = "LLM_PRE_DEDUCT_CONFIG_INVALID" CHARGE_INSUFFICIENT = "LLM_CHARGE_INSUFFICIENT"
CHARGE_CONFIG_INVALID = "LLM_CHARGE_CONFIG_INVALID"
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START" EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS" EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED" EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
+1 -1
View File
@@ -405,7 +405,7 @@ async def _seed_data():
("/generation-ai", "创作记录", "BulbOutlined", 3, None), ("/generation-ai", "创作记录", "BulbOutlined", 3, None),
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None), ("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
("/credit-products", "积分产品", "GiftOutlined", 4, None), ("/credit-products", "积分产品", "GiftOutlined", 4, None),
("/llm-billing-policies", "LLM预扣配置", "RobotOutlined", 5, "模型设置"), ("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"),
("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"), ("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"),
("/notifications", "消息推送", "BellOutlined", 5, None), ("/notifications", "消息推送", "BellOutlined", 5, None),
("/payment-stats", "支付统计", "LineChartOutlined", 6, None), ("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
+4 -8
View File
@@ -8,13 +8,13 @@ from pydantic import BaseModel, Field
class LlmBillingPolicyCreate(BaseModel): class LlmBillingPolicyCreate(BaseModel):
scene_code: str = Field(..., min_length=1, max_length=64) scene_code: str = Field(..., min_length=1, max_length=64)
scene_name: str | None = Field(default=None, min_length=1, max_length=128) scene_name: str | None = Field(default=None, min_length=1, max_length=128)
pre_deduct_credits: float = Field(..., ge=0, le=999999999.99, multiple_of=0.01) charge_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
is_active: bool = True is_active: bool = True
class LlmBillingPolicyUpdate(BaseModel): class LlmBillingPolicyUpdate(BaseModel):
scene_name: str | None = Field(default=None, min_length=1, max_length=128) scene_name: str | None = Field(default=None, min_length=1, max_length=128)
pre_deduct_credits: float | None = Field(default=None, ge=0, le=999999999.99, multiple_of=0.01) charge_credits: float | None = Field(default=None, gt=0, le=999999999.99, multiple_of=0.01)
is_active: bool | None = None is_active: bool | None = None
@@ -22,7 +22,7 @@ class LlmBillingPolicyOut(BaseModel):
id: str id: str
scene_code: str scene_code: str
scene_name: str scene_name: str
pre_deduct_credits: float charge_credits: float
is_active: bool is_active: bool
version: int version: int
created_by: str | None = None created_by: str | None = None
@@ -30,8 +30,6 @@ class LlmBillingPolicyOut(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = {"from_attributes": True}
class LlmCallAttemptOut(BaseModel): class LlmCallAttemptOut(BaseModel):
id: str id: str
@@ -70,7 +68,7 @@ class LlmBillingExecutionOut(BaseModel):
model_name_snapshot: str | None = None model_name_snapshot: str | None = None
provider_snapshot: str | None = None provider_snapshot: str | None = None
request_time: datetime request_time: datetime
pre_deduct_credits: float charge_credits: float
status: str status: str
total_call_count: int total_call_count: int
successful_call_count: int successful_call_count: int
@@ -84,5 +82,3 @@ class LlmBillingExecutionOut(BaseModel):
completed_at: datetime | None = None completed_at: datetime | None = None
refunded_at: datetime | None = None refunded_at: datetime | None = None
calls: list[LlmCallAttemptOut] = Field(default_factory=list) calls: list[LlmCallAttemptOut] = Field(default_factory=list)
model_config = {"from_attributes": True}
@@ -6,6 +6,11 @@ from typing import Any
from sqlalchemy import and_, case, distinct, func, or_, select 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_balance import (
CREDIT_ALLOCATION_ACTION_LABELS,
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_LEVEL_LABELS,
)
from app.enums.credit_record import ( from app.enums.credit_record import (
CREDIT_RECORD_ACTION_LABELS, CREDIT_RECORD_ACTION_LABELS,
CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_BILLING_SCENE_LABELS,
@@ -129,6 +134,22 @@ def _build_filters(
if charge_kind: if charge_kind:
filters.append(CreditRecord.charge_kind == charge_kind) filters.append(CreditRecord.charge_kind == charge_kind)
if charge_action: if charge_action:
if charge_action == "charge":
# 历史 LLM Billing 已经真实减余额但元数据曾写成 pre_deduct,查询时统一按真实消费兼容。
filters.append(or_(
CreditRecord.charge_action == "charge",
and_(
CreditRecord.charge_action == "pre_deduct",
CreditRecord.llm_billing_execution_id.is_not(None),
),
))
elif charge_action == "pre_deduct":
# 只展示真正的历史固定预扣,不把历史 LLM 真实消费混入。
filters.append(and_(
CreditRecord.charge_action == "pre_deduct",
CreditRecord.llm_billing_execution_id.is_(None),
))
else:
filters.append(CreditRecord.charge_action == 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)
@@ -172,6 +193,25 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
return deleted_map return deleted_map
def _is_legacy_llm_charge(record: CreditRecord) -> bool:
return bool(
record.llm_billing_execution_id
and record.charge_action == "pre_deduct"
and record.type == "consume"
)
def _normalized_charge_action(record: CreditRecord) -> str | None:
return "charge" if _is_legacy_llm_charge(record) else record.charge_action
def _normalized_description(record: CreditRecord) -> str | None:
description = record.description
if _is_legacy_llm_charge(record) and description:
return description.replace("固定预扣积分", "积分消费").replace("固定预扣", "积分消费")
return description
def _record_to_item( def _record_to_item(
record: CreditRecord, record: CreditRecord,
user: User | None, user: User | None,
@@ -185,6 +225,7 @@ def _record_to_item(
user_type = record.user_type_snapshot or (user.user_type if user else None) user_type = record.user_type_snapshot or (user.user_type if user else None)
frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None) frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None)
charge_action = _normalized_charge_action(record)
return { return {
"id": record.id, "id": record.id,
"user_id": record.user_id, "user_id": record.user_id,
@@ -204,7 +245,7 @@ def _record_to_item(
"balance_delta": _round2(record.balance_delta), "balance_delta": _round2(record.balance_delta),
"expired_amount": _round2(record.expired_amount), "expired_amount": _round2(record.expired_amount),
"balance_after": _round2(record.balance_after), "balance_after": _round2(record.balance_after),
"description": record.description, "description": _normalized_description(record),
"related_id": record.related_id, "related_id": record.related_id,
"biz_key": record.biz_key, "biz_key": record.biz_key,
"refund_for_biz_key": record.refund_for_biz_key, "refund_for_biz_key": record.refund_for_biz_key,
@@ -215,8 +256,8 @@ def _record_to_item(
"attempt_no": record.attempt_no, "attempt_no": record.attempt_no,
"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": charge_action,
"charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, record.charge_action), "charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, 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,
@@ -321,9 +362,12 @@ async def list_admin_credit_records(
"credit_balance_id": allocation.credit_balance_id, "credit_balance_id": allocation.credit_balance_id,
"source_allocation_id": allocation.source_allocation_id, "source_allocation_id": allocation.source_allocation_id,
"allocation_action": allocation.allocation_action, "allocation_action": allocation.allocation_action,
"allocation_action_label": _label(CREDIT_ALLOCATION_ACTION_LABELS, allocation.allocation_action),
"amount": _round2(allocation.amount), "amount": _round2(allocation.amount),
"credit_level": allocation.credit_level_snapshot, "credit_level": allocation.credit_level_snapshot,
"credit_level_label": _label(CREDIT_LEVEL_LABELS, allocation.credit_level_snapshot),
"source_type": allocation.source_type_snapshot, "source_type": allocation.source_type_snapshot,
"source_type_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
"source_id": allocation.source_id_snapshot, "source_id": allocation.source_id_snapshot,
"valid_from": _iso(allocation.valid_from_snapshot), "valid_from": _iso(allocation.valid_from_snapshot),
"expires_at": _iso(allocation.expires_at_snapshot), "expires_at": _iso(allocation.expires_at_snapshot),
@@ -334,61 +378,39 @@ async def list_admin_credit_records(
}) })
items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows] items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows]
# 说明: # 当前主口径只统计真实消费/真实退款;hold/hold_release 仅保留历史兼容字段,不再并入总消费/总退款。
# - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值 # 历史 LLM Billing 的 consume + pre_deduct + llm_billing_execution_id 本质已经真实减余额,
# team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费 # 查询统计时按 charge 兼容,但不批量改写历史数据库。
# - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正 _legacy_llm_charge_action = and_(
# 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release') CreditRecord.charge_action == "pre_deduct",
# - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs CreditRecord.llm_billing_execution_id.is_not(None),
# )
# 口径更新(Bug 修复 · 第二次修正):
# 1. 消费类统计仅看 type=consume(排除 team_internal 团队内部转账)
# 2. 真实扣费 / 预扣占用 / 真实退款 / 预扣释放 全部改为"独立统计列",不再用差值推导
# (避免任何一类范围不同导致推导失真)
#
# 消费类(type=consume):
# - total_charge :真实扣费 charge_action in (NULL, 'charge') abs 求和
# - total_hold :预扣占用 charge_action = 'hold' abs 求和
# - total_consume total_charge + total_hold = charge_action in (NULL, charge, hold) abs 求和
# 回退类(type=refund):
# - total_refund_real :真实退款 charge_action in (NULL, 'refund') abs 求和
# - total_hold_release :预扣释放 charge_action = 'hold_release' abs 求和
# - total_refund total_refund_real + total_hold_release = type=refund 全部 abs 求和
# 净消耗 net_consume = max(total_consume - total_refund, 0)
#
# 按积分 subject 分类的子项(图片/视频/提词/分析)仍保持「仅真实扣费 charge」口径不变:
# 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。
_real_charge_action = or_( _real_charge_action = or_(
CreditRecord.charge_action.is_(None), CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge", CreditRecord.charge_action == "charge",
) _legacy_llm_charge_action,
_charge_or_hold_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
CreditRecord.charge_action == "hold",
) )
_real_refund_action = or_( _real_refund_action = or_(
CreditRecord.charge_action.is_(None), CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "refund", CreditRecord.charge_action == "refund",
) )
# 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账)
_consume_type = CreditRecord.type == "consume" _consume_type = CreditRecord.type == "consume"
# 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund _refund_type = CreditRecord.type == "refund"
_hold_release_filter = and_( _hold_release_filter = and_(
CreditRecord.type == "refund", _refund_type,
CreditRecord.charge_action == "hold_release", CreditRecord.charge_action == "hold_release",
) )
_refund_type = CreditRecord.type == "refund"
summary_query = select( summary_query = select(
# 0: 充值 # 0: 充值
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),
# 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用) # 1: 总真实消费
func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水) # 2: 总真实退款
func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 3: 交易笔数 # 3: 交易笔数
func.count(CreditRecord.id), func.count(CreditRecord.id),
# 4-11: 生成条数 / 尝试次数 / 图片视频条数 / 图片视频提词分析消费(仍按 charge 口径) # 4-11: 生成统计与按 subject 的真实消费
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)), func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, _consume_type, _real_charge_action), 1), else_=None)),
func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", _consume_type, _real_charge_action), 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", _consume_type, _real_charge_action), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))),
@@ -397,17 +419,17 @@ async def list_admin_credit_records(
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, _consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 12-14: Token # 12-14: Token 仅统计真实 LLM/媒体消费流水,避免退款/历史释放重复累计。
func.coalesce(func.sum(CreditRecord.total_tokens), 0), func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.total_tokens), else_=0)), 0),
func.coalesce(func.sum(CreditRecord.input_tokens), 0), func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.input_tokens), else_=0)), 0),
func.coalesce(func.sum(CreditRecord.output_tokens), 0), func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.output_tokens), else_=0)), 0),
# 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge') # 15: total_charge 与 total_consume 同口径,保留字段供旧前端兼容。
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 16: 预扣占用 total_hold独立列:type=consume AND charge_action='hold' # 16: 历史 hold 独立统计,不计入总消费。
func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(_consume_type, CreditRecord.charge_action == "hold"), func.abs(CreditRecord.amount)), else_=0)), 0),
# 17: 真实退款 total_refund_real(独立列:type=refund AND charge_action in (NULL, 'refund') # 17: 真实退款,与 total_refund 同口径。
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 18: 预扣释放 total_hold_release独立列:type=refund AND charge_action='hold_release' # 18: 历史 hold_release 独立统计,不计入总退款。
func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((_hold_release_filter, func.abs(CreditRecord.amount)), else_=0)), 0),
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True) ).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
if where_clause is not None: if where_clause is not None:
-5
View File
@@ -10,11 +10,6 @@ from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio from app.models.credit_ratio import CreditRatio
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。"""
return 0.0
async def _get_credit_ratio( async def _get_credit_ratio(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any, Mapping from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,34 +11,20 @@ from app.enums.credit_record import CreditRecordChargeKind, CreditRecordOwnerTyp
from app.models.credit_record import CreditRecord from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.services.generation.media_reference_service import calculate_media_reference_usage from app.services.generation.media_reference_service import calculate_media_reference_usage
from app.models.module_generation_step import ModuleGenerationStep
from app.models.token_usage import TokenUsage
from app.models.system_config import SystemConfig
from app.services.credit_record_meta_service import ( from app.services.credit_record_meta_service import (
CreditRecordMeta, CreditRecordMeta,
build_generation_media_meta, build_generation_media_meta,
build_generation_record_prompt_meta,
build_module_step_prompt_meta,
build_shot_video_analysis_meta,
) )
from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits_result from app.services.credits import calc_image_credits, calc_video_credits, deduct_credits_result
from app.utils.id_gen import generate_id
CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value
CHARGE_FILE_PARSE = CreditRecordChargeKind.FILE_PARSE.value
CHARGE_VISION_INPUT = CreditRecordChargeKind.VISION_INPUT.value
CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value
CHARGE_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value
OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value
OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value
OWNER_MODULE_GENERATION_STEP = CreditRecordOwnerType.MODULE_GENERATION_STEP.value
OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.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|pre_deduct|hold|hold_release)$" r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|llm_charge|refund|pre_deduct|hold|hold_release)$"
) )
@@ -76,15 +62,6 @@ def _round2(value: float | int | None) -> float:
return round(float(value or 0), 2) return round(float(value or 0), 2)
def _safe_int(value: Any, default: int = 0) -> int:
try:
if value is None:
return default
return int(value)
except Exception:
return default
def build_credit_biz_key( def build_credit_biz_key(
*, *,
owner_type: str, owner_type: str,
@@ -101,8 +78,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", "pre_deduct", "hold", "hold_release"): if action not in ("charge", "llm_charge", "refund", "pre_deduct", "hold", "hold_release"):
raise ValueError("action 仅支持 charge/refund/pre_deduct/hold/hold_release") raise ValueError("action 仅支持 charge/llm_charge/refund/pre_deduct/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}"
@@ -119,27 +96,6 @@ def parse_credit_biz_key(biz_key: str | None) -> dict[str, Any] | None:
return data return data
async def _get_config_float_or_none(db: AsyncSession, key: str) -> float | None:
result = await db.execute(select(SystemConfig).where(SystemConfig.key == key).limit(1))
config = result.scalar_one_or_none()
if not config:
return None
try:
return float(config.value)
except Exception:
return None
async def _calc_optional_token_credits(db: AsyncSession, tokens: int, config_key: str) -> float:
tokens = _safe_int(tokens)
if tokens <= 0:
return 0.0
rate = await _get_config_float_or_none(db, config_key)
if rate is None:
return 0.0
return round(tokens * rate / 1000, 2)
async def get_next_credit_attempt_no( async def get_next_credit_attempt_no(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -215,271 +171,6 @@ async def deduct_credits_locked_once(
) )
async def charge_chatapi_prompt_usage(
db: AsyncSession,
*,
record: GenerationRecord,
usage: Mapping[str, Any],
project_name: str | None = None,
) -> BillingSummary:
"""项目记录提示词整理扣费;不参与生成失败媒体退款。"""
project_name = project_name or "AI生成任务"
items: list[BillingItem] = []
attempt_no = 1
owner_type = OWNER_GENERATION_RECORD
owner_id = record.id
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
text_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=usage,
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=text_credits,
description=f"提示词优化 - {project_name}",
related_id=record.id,
charge_key=CHARGE_TEXT_PROMPT,
biz_key=build_credit_biz_key(
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
),
attempt_no=attempt_no,
record_meta=text_meta,
)
)
file_tokens = usage.get("file_parse_tokens") or usage.get("file_tokens") or usage.get("document_tokens") or 0
file_parse_credits = await _calc_optional_token_credits(db, _safe_int(file_tokens), "file_parse_credits_per_1000_tokens")
file_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=file_parse_credits,
description="文件解析Token",
related_id=record.id,
charge_key=CHARGE_FILE_PARSE,
biz_key=build_credit_biz_key(
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
action="charge",
),
attempt_no=attempt_no,
record_meta=file_meta,
)
)
vision_tokens = usage.get("vision_input_tokens") or usage.get("image_input_tokens") or usage.get("image_tokens") or 0
vision_input_credits = await _calc_optional_token_credits(db, _safe_int(vision_tokens), "vision_input_credits_per_1000_tokens")
vision_meta = await build_generation_record_prompt_meta(
db,
record_id=record.id,
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
)
items.append(
await deduct_credits_locked_once(
db,
user_id=record.user_id,
amount=vision_input_credits,
description="图片理解Token",
related_id=record.id,
charge_key=CHARGE_VISION_INPUT,
biz_key=build_credit_biz_key(
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
action="charge",
),
attempt_no=attempt_no,
record_meta=vision_meta,
)
)
if hasattr(record, "text_credits_cost"):
record.text_credits_cost = round(text_credits + file_parse_credits + vision_input_credits, 2)
if hasattr(record, "text_tokens_used"):
record.text_tokens_used = _safe_int(usage.get("total_tokens"), input_tokens + output_tokens)
return BillingSummary(record_id=record.id, user_id=record.user_id, items=items)
async def charge_module_prompt_usage(
db: AsyncSession,
*,
user_id: str,
step_id: str,
usage: Mapping[str, Any],
description: str,
attempt_no: int = 1,
) -> BillingSummary:
"""爆款开头复刻/拆镜复刻模块图片/视频 AI 提词扣文本积分。
文本提词属于已经发生的 LLM 消费:
- 调用成功后按 input_tokens + output_tokens 扣费。
- 不参与后续图片/视频媒体生成失败退款。
- 通过 module_generation_step:{step_id}:attempt:1:text_prompt:charge 幂等。
"""
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
text_credits = await calc_text_credits(db, input_tokens, output_tokens)
biz_key = build_credit_biz_key(
owner_type=OWNER_MODULE_GENERATION_STEP,
owner_id=step_id,
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
action="charge",
)
record_meta = await build_module_step_prompt_meta(db, step_id=step_id, attempt_no=attempt_no, usage=usage)
item = await deduct_credits_locked_once(
db,
user_id=user_id,
amount=text_credits,
description=description,
related_id=step_id,
charge_key=CHARGE_TEXT_PROMPT,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1))
step = result.scalar_one_or_none()
if step:
step.token_usage_id = record_meta.token_usage_id
step.model_config_id = usage.get("model_config_id")
step.input_tokens = record_meta.input_tokens
step.output_tokens = record_meta.output_tokens
step.total_tokens = record_meta.total_tokens
step.text_credits_cost = text_credits
return BillingSummary(record_id=step_id, user_id=user_id, items=[item])
async def charge_shot_video_analysis_usage(
db: AsyncSession,
*,
user_id: str,
owner_type: str,
owner_id: str,
usage: Mapping[str, Any],
description: str,
billing_scene: str,
source_project_id: str | None = None,
source_step_id: str | None = None,
attempt_no: int | None = None,
) -> BillingSummary:
"""拆镜复刻视频分析扣分析积分。
视频分析属于“文字提示词 + 视频素材”的模型调用类消费,
按 input_tokens + output_tokens 参考文本积分规则计费,
但账务归类为 analysis/video_analysis,避免混入提词优化统计。
"""
attempt_no = attempt_no or await get_next_credit_attempt_no(
db,
owner_type=owner_type,
owner_id=owner_id,
charge_kind=CHARGE_VIDEO_ANALYSIS,
)
input_tokens = _safe_int(usage.get("input_tokens"))
output_tokens = _safe_int(usage.get("output_tokens"))
amount = await calc_text_credits(db, input_tokens, output_tokens)
biz_key = build_credit_biz_key(
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
charge_kind=CHARGE_VIDEO_ANALYSIS,
action="charge",
)
usage_snapshot = dict(usage)
token_usage_result = await db.execute(
select(TokenUsage)
.where(
TokenUsage.owner_type == owner_type,
TokenUsage.owner_id == owner_id,
TokenUsage.biz_key == biz_key,
)
.order_by(TokenUsage.created_at.asc())
.limit(1)
)
token_usage = token_usage_result.scalar_one_or_none()
if token_usage is None:
token_usage = TokenUsage(
id=generate_id(),
model_config_id=usage_snapshot.get("model_config_id"),
user_id=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=owner_type,
owner_id=owner_id,
biz_key=biz_key,
source_module=CreditRecordSourceModule.SHOT_REPLICATE.value,
source_step_code="video_analysis",
)
db.add(token_usage)
await db.flush()
usage_snapshot["token_usage_id"] = token_usage.id
record_meta = await build_shot_video_analysis_meta(
db,
owner_type=owner_type,
owner_id=owner_id,
attempt_no=attempt_no,
usage=usage_snapshot,
billing_scene=billing_scene,
source_project_id=source_project_id,
source_step_id=source_step_id,
)
item = await deduct_credits_locked_once(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=owner_id,
charge_key=CHARGE_VIDEO_ANALYSIS,
biz_key=biz_key,
attempt_no=attempt_no,
record_meta=record_meta,
)
if record_meta.token_usage_id:
result = await db.execute(select(TokenUsage).where(TokenUsage.id == record_meta.token_usage_id).limit(1))
token_usage = result.scalar_one_or_none()
if token_usage:
token_usage.owner_type = token_usage.owner_type or owner_type
token_usage.owner_id = token_usage.owner_id or owner_id
token_usage.biz_key = token_usage.biz_key or biz_key
token_usage.source_module = token_usage.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value
token_usage.source_step_code = token_usage.source_step_code or "video_analysis"
return BillingSummary(record_id=owner_id, user_id=user_id, items=[item])
async def charge_generation_media_by_params( async def charge_generation_media_by_params(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -55,7 +55,7 @@ from app.services.llm_billing import (
log_provider_success, log_provider_success,
finalize_llm_business_failure, finalize_llm_business_failure,
mark_business_success, mark_business_success,
pre_deduct, charge_llm_credits,
) )
from app.services.operation_log_service import log_operation_error, log_operation_event from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
@@ -466,7 +466,7 @@ async def optimize_generation_prompt(
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key) ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key)
try: try:
await pre_deduct(db, ctx) await charge_llm_credits(db, ctx)
await db.commit() await db.commit()
except Exception: except Exception:
await db.rollback() await db.rollback()
@@ -638,7 +638,7 @@ async def optimize_generation_prompt(
) )
# 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后, # 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后,
# 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按 # 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按
# 原积分来源退回固定预扣,不能让账务永久停留在 processing。 # 原积分来源退回场景积分消费,不能让账务永久停留在 processing。
business_already_succeeded = False business_already_succeeded = False
try: try:
await db.rollback() await db.rollback()
@@ -672,7 +672,7 @@ async def optimize_generation_prompt(
if business_already_succeeded: if business_already_succeeded:
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True) return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
await finalize_llm_business_failure(ctx, error=str(failure)) await finalize_llm_business_failure(ctx, error=str(failure))
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,固定预扣积分已按原来源退回") raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,场景消费积分已按原来源退回")
_log_event( _log_event(
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED, GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED,
@@ -1,273 +0,0 @@
from __future__ import annotations
import json
import time
from types import SimpleNamespace
from typing import Any
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
from app.services.generation.log_service import log_provider_call
from app.services.provider_limit import provider_limit
from app.utils.id_gen import generate_id
def _absolute_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://") or url.startswith("data:"):
return url
base = settings.BASE_URL.rstrip("/")
return f"{base}/{url.lstrip('/')}"
def _load_refs(record: ChatGenerationTask) -> list[dict]:
if not record.media_references:
return []
try:
data = json.loads(record.media_references)
return data if isinstance(data, list) else []
except Exception:
return []
async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
from app.utils.media import get_llm_media_as_base64, media_to_base64
if record.gen_type == "image":
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
else:
params = f"视频参数:时长={record.duration or 4}秒,比例={record.aspect_ratio or '16:9'},分辨率={record.resolution or '480p'}"
text = (
f"生成类型:{record.gen_type}\n"
f"{params}\n"
f"用户描述:{record.original_prompt}\n\n"
"请只输出最终可直接用于图片/视频生成模型的 prompt,不要说你已经生成了图片或视频。"
)
parts: list[dict[str, Any]] = [{"type": "text", "text": text}]
for ref in _load_refs(record):
ref_type = ref.get("type")
ref_url = ref.get("url") or ""
if not ref_url:
continue
if db and await get_llm_media_as_base64(db):
if ref_type == "image":
url = await media_to_base64(ref_url, "image/png")
elif ref_type == "video":
url = await media_to_base64(ref_url, "video/mp4")
else:
continue
else:
url = _absolute_url(ref_url)
if ref_type == "image":
parts.append({"type": "image_url", "image_url": {"url": url}})
elif ref_type == "video":
parts.append({"type": "video_url", "video_url": {"url": url}})
return parts
async def _get_model_config(db: AsyncSession) -> ModelConfig:
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
.order_by(ModelConfig.priority.desc())
.limit(1)
)
config = result.scalar_one_or_none()
if not config:
raise ValueError("没有可用的ChatAPI模型配置")
if config.provider == "mock":
return config
if not config.api_base or not config.api_key or not config.model_name:
raise ValueError("ChatAPI模型配置不完整")
return config
async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask) -> tuple[str, dict]:
"""Call ChatAPI once with current request params and attachments. No history context."""
config_row = await _get_model_config(db)
if config_row.provider == "mock":
original_prompt = str(record.original_prompt or "")
await db.commit()
return original_prompt, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
user_content = await _build_user_content(record, db)
config = SimpleNamespace(
id=str(config_row.id),
name=str(config_row.name or ""),
provider=str(config_row.provider or ""),
api_base=str(config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
max_tokens=config_row.max_tokens,
temperature=config_row.temperature,
)
record = SimpleNamespace(
id=str(record.id),
user_id=str(record.user_id),
engine_id=str(record.engine_id or "") or None,
generation_mode=str(record.generation_mode or ""),
generation_attempt_no=int(record.generation_attempt_no or 1),
)
# Release all configuration/media lookup reads before the remote request.
await db.commit()
system_prompt = (
"你是图片/视频生成提示词整理助手。你的职责是根据用户文字、上传图片/视频和生成参数,"
"整理最终可直接用于生成模型的 prompt。不要声称你已经生成图片或视频,不要调用工具。"
"输出中文为主,内容具体、可执行,保留用户关键要求。"
)
request_data = {
"model": config.model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
"max_tokens": config.max_tokens,
"temperature": config.temperature,
}
started = time.perf_counter()
call_id = await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="request",
request_data=request_data,
module="generation_record",
step_code="prompt_optimize",
)
async with provider_limit("ark_chat_prompt", settings.ARK_CHAT_PROMPT_MAX_CONCURRENCY):
async with httpx.AsyncClient(timeout=settings.CHATAPI_REQUEST_TIMEOUT_SECONDS) as client:
response: httpx.Response | None = None
try:
response = await client.post(
f"{config.api_base.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json",
},
json=request_data,
)
latency_ms = int((time.perf_counter() - started) * 1000)
if response.status_code >= 400:
message = response.text[:5000]
await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="failed",
latency_ms=latency_ms,
http_status=response.status_code,
response_data=response.text,
error_message=message,
call_id=call_id,
module="generation_record",
step_code="prompt_optimize",
)
raise RuntimeError(f"ChatAPI HTTP {response.status_code}: {message}")
data = response.json()
except Exception as exc:
if isinstance(exc, RuntimeError) and str(exc).startswith("ChatAPI HTTP "):
raise
latency_ms = int((time.perf_counter() - started) * 1000)
await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="failed",
latency_ms=latency_ms,
http_status=response.status_code if response is not None else None,
response_data=response.text if response is not None else None,
error_message=str(exc),
call_id=call_id,
module="generation_record",
step_code="prompt_optimize",
)
raise
usage = data.get("usage", {}) or {}
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
output_tokens = int(usage.get("completion_tokens", 0) or 0)
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="success",
latency_ms=int((time.perf_counter() - started) * 1000),
http_status=response.status_code if response is not None else 200,
response_data=data,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
total_tokens=total_tokens,
call_id=call_id,
module="generation_record",
step_code="prompt_optimize",
)
content = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
if not content:
await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="failed",
error_message="ChatAPI未返回有效prompt",
call_id=call_id,
module="generation_record",
step_code="prompt_optimize",
)
raise RuntimeError("ChatAPI未返回有效prompt")
token_usage_id = generate_id()
try:
db.add(TokenUsage(
id=token_usage_id,
model_config_id=config.id,
user_id=record.user_id,
owner_type="generation_record",
owner_id=record.id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
))
await db.flush()
except Exception as exc:
await log_provider_call(
record,
provider=config.provider,
api_type="chat_prompt",
model=config.model_name,
engine_id=record.engine_id,
status="failed",
error_message=f"token usage写入失败: {exc}",
call_id=call_id,
module="generation_record",
step_code="prompt_optimize",
)
raise
return content, {
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
}
@@ -54,7 +54,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt from app.services.llm import optimize_prompt
from app.services.llm_billing import ( from app.services.llm_billing import (
LlmBillingContext, LlmBillingContext,
ensure_pre_deducted, ensure_llm_charged,
log_provider_failure, log_provider_failure,
record_provider_exception, record_provider_exception,
log_provider_start, log_provider_start,
@@ -62,7 +62,7 @@ from app.services.llm_billing import (
refund_on_final_failure, refund_on_final_failure,
finalize_llm_business_failure, finalize_llm_business_failure,
mark_business_success, mark_business_success,
pre_deduct, charge_llm_credits,
) )
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,
@@ -893,7 +893,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
LlmBillingContext( LlmBillingContext(
user_id=str(project.user_id), user_id=str(project.user_id),
@@ -1008,10 +1008,10 @@ async def run_image_prompt_optimize(
description_prefix="爆款开头复刻图片AI提词优化", description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"hot-opening-image-prompt:{step_id_value}", trace_id=f"hot-opening-image-prompt:{step_id_value}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now() step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message project.error_message = step.error_message
@@ -1327,7 +1327,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
LlmBillingContext( LlmBillingContext(
user_id=str(project.user_id), user_id=str(project.user_id),
@@ -1440,10 +1440,10 @@ async def run_video_prompt_optimize(
description_prefix="爆款开头复刻视频AI提词优化", description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"hot-opening-video-prompt:{step_id_value}", trace_id=f"hot-opening-video-prompt:{step_id_value}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now() step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message project.error_message = step.error_message
@@ -3,24 +3,24 @@ from app.services.llm_billing.context import (
LlmBillingContext, LlmBillingContext,
LlmBillingPolicy, LlmBillingPolicy,
LlmBillingStateError, LlmBillingStateError,
LlmPreDeductResult, LlmChargeResult,
LlmPreDeductValidation, LlmChargeValidation,
) )
from app.services.llm_billing.service import ( from app.services.llm_billing.service import (
ensure_pre_deducted, charge_llm_credits,
ensure_llm_charged,
finalize_llm_business_failure,
get_llm_ledger_states, get_llm_ledger_states,
log_celery_dispatch_compensated, log_celery_dispatch_compensated,
log_celery_dispatch_failure, log_celery_dispatch_failure,
log_celery_dispatch_start, log_celery_dispatch_start,
log_celery_dispatch_success, log_celery_dispatch_success,
log_provider_failure, log_provider_failure,
record_provider_exception,
log_provider_start, log_provider_start,
log_provider_success, log_provider_success,
refund_on_final_failure,
finalize_llm_business_failure,
mark_business_success, mark_business_success,
pre_deduct, record_provider_exception,
refund_on_final_failure,
validate_retryable_previous_attempt, validate_retryable_previous_attempt,
) )
@@ -29,10 +29,10 @@ __all__ = [
"LlmBillingContext", "LlmBillingContext",
"LlmBillingPolicy", "LlmBillingPolicy",
"LlmBillingStateError", "LlmBillingStateError",
"LlmPreDeductResult", "LlmChargeResult",
"LlmPreDeductValidation", "LlmChargeValidation",
"pre_deduct", "charge_llm_credits",
"ensure_pre_deducted", "ensure_llm_charged",
"get_llm_ledger_states", "get_llm_ledger_states",
"validate_retryable_previous_attempt", "validate_retryable_previous_attempt",
"log_provider_start", "log_provider_start",
@@ -6,7 +6,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.llm_billing import LlmBillingExecutionStatus, LlmCallAttemptStatus from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingExecutionStatus, LlmCallAttemptStatus
from app.models.base import AsyncSessionLocal from app.models.base import AsyncSessionLocal
from app.models.credit_record import CreditRecord from app.models.credit_record import CreditRecord
from app.models.llm_billing.call_attempt import LlmCallAttempt from app.models.llm_billing.call_attempt import LlmCallAttempt
@@ -14,9 +14,35 @@ from app.models.llm_billing.execution import LlmBillingExecution
from app.models.token_usage import TokenUsage from app.models.token_usage import TokenUsage
from app.services.credit.utils import utc_now from app.services.credit.utils import utc_now
from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
def _audit_log(ctx: LlmBillingContext, event: LlmBillingEvent, **detail: Any) -> None:
log_operation_event(
domain=LlmBillingDomain.LLM_BILLING.value,
module=ctx.source_module or "llm",
event_type=event.value,
event_status="success",
trace_id=ctx.trace_id,
request_id=ctx.request_id,
user_id=ctx.user_id,
project_id=ctx.source_project_id,
task_id=ctx.celery_task_id,
step_id=ctx.source_step_id,
message=ctx.description_prefix or ctx.billing_scene or "LLM调用审计",
detail={
"scene_code": ctx.billing_scene,
"owner_type": ctx.owner_type,
"owner_id": ctx.owner_id,
"attempt_no": ctx.attempt_no,
"billing_execution_id": ctx.billing_execution_id,
"call_attempt_id": ctx.current_call_attempt_id,
**detail,
},
)
def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int: def _usage_int(usage: Mapping[str, Any] | None, *keys: str) -> int:
for key in keys: for key in keys:
value = (usage or {}).get(key) value = (usage or {}).get(key)
@@ -43,11 +69,12 @@ async def create_call_attempt(
) )
execution = result.scalar_one_or_none() execution = result.scalar_one_or_none()
if execution is None: if execution is None:
raise LlmBillingStateError("LLM固定预扣执行记录不存在,禁止调用模型") raise LlmBillingStateError("LLM积分消费执行记录不存在,禁止调用模型")
if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id: if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id:
raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换") raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换")
if execution.status not in { if execution.status not in {
LlmBillingExecutionStatus.PRE_DEDUCTED.value, LlmBillingExecutionStatus.CHARGED.value,
LlmBillingExecutionStatus.PRE_DEDUCTED.value, # 历史兼容
LlmBillingExecutionStatus.PROCESSING.value, LlmBillingExecutionStatus.PROCESSING.value,
}: }:
raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}") raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}")
@@ -90,7 +117,7 @@ async def _get_or_create_token_usage(
input_tokens: int, input_tokens: int,
output_tokens: int, output_tokens: int,
total_tokens: int, total_tokens: int,
) -> TokenUsage: ) -> tuple[TokenUsage, bool]:
"""先持久化 TokenUsage,再允许任何外键引用它。 """先持久化 TokenUsage,再允许任何外键引用它。
LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy LlmCallAttempt 仅保存 token_usage_id 字符串,没有 ORM relationship。SQLAlchemy
@@ -105,7 +132,7 @@ async def _get_or_create_token_usage(
) )
existing = existing_result.scalar_one_or_none() existing = existing_result.scalar_one_or_none()
if existing is not None: if existing is not None:
return existing return existing, False
usage_row = TokenUsage( usage_row = TokenUsage(
id=generate_id(), id=generate_id(),
@@ -136,8 +163,8 @@ async def _get_or_create_token_usage(
existing = existing_result.scalar_one_or_none() existing = existing_result.scalar_one_or_none()
if existing is None: if existing is None:
raise raise
return existing return existing, False
return usage_row return usage_row, True
async def finish_call_success( async def finish_call_success(
@@ -167,7 +194,7 @@ async def finish_call_success(
ctx.token_usage_id = attempt.token_usage_id ctx.token_usage_id = attempt.token_usage_id
return return
previous_status = attempt.status previous_status = attempt.status
usage_row = await _get_or_create_token_usage( usage_row, token_usage_created = await _get_or_create_token_usage(
db, db,
ctx=ctx, ctx=ctx,
attempt=attempt, attempt=attempt,
@@ -222,8 +249,15 @@ async def finish_call_success(
record.llm_call_count = execution.total_call_count record.llm_call_count = execution.total_call_count
record.llm_success_call_count = execution.successful_call_count record.llm_success_call_count = execution.successful_call_count
record.llm_failed_call_count = execution.failed_call_count record.llm_failed_call_count = execution.failed_call_count
record.token_usage_id = token_usage_id
ctx.token_usage_id = token_usage_id ctx.token_usage_id = token_usage_id
_audit_log(
ctx,
LlmBillingEvent.TOKEN_USAGE_CREATED if token_usage_created else LlmBillingEvent.TOKEN_USAGE_REUSED,
token_usage_id=token_usage_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str: async def finish_call_failure(ctx: LlmBillingContext, *, error: str, status: str = "failed") -> str:
@@ -12,11 +12,11 @@ async def get_llm_billing_policy(
*, *,
scene_code: str | None = None, scene_code: str | None = None,
) -> LlmBillingPolicy: ) -> LlmBillingPolicy:
"""按业务场景读取固定预扣;不再读取旧 SystemConfig 冻结配置。""" """按业务场景读取固定消费积分;不再读取旧 SystemConfig 冻结/预扣配置。"""
resolved_scene = scene_code resolved_scene = scene_code
if not resolved_scene: if not resolved_scene:
return LlmBillingPolicy( return LlmBillingPolicy(
pre_deduct_credits=0.0, charge_credits=0.0,
valid=False, valid=False,
error="LLM业务场景不能为空", error="LLM业务场景不能为空",
) )
@@ -28,25 +28,25 @@ async def get_llm_billing_policy(
model = result.scalar_one_or_none() model = result.scalar_one_or_none()
if model is None: if model is None:
return LlmBillingPolicy( return LlmBillingPolicy(
pre_deduct_credits=0.0, charge_credits=0.0,
valid=False, valid=False,
error=f"未配置LLM场景固定预扣积分:{resolved_scene}", error=f"未配置LLM场景消费积分:{resolved_scene}",
) )
# 数据库物理字段 pre_deduct_credits 为历史命名,本轮不迁移字段;业务语义统一为 charge_credits。
amount = float(model.pre_deduct_credits) amount = float(model.pre_deduct_credits)
if not model.is_active or amount <= 0: if not model.is_active or amount <= 0:
return LlmBillingPolicy( return LlmBillingPolicy(
pre_deduct_credits=amount, charge_credits=amount,
valid=False, valid=False,
error=f"LLM场景固定预扣配置未启用或金额无效:{resolved_scene}", error=f"LLM场景积分配置未启用或金额无效:{resolved_scene}",
policy_id=model.id, policy_id=model.id,
version=model.version, version=model.version,
scene_name=model.scene_name, scene_name=model.scene_name,
) )
return LlmBillingPolicy( return LlmBillingPolicy(
pre_deduct_credits=round(amount, 2), charge_credits=round(amount, 2),
valid=True, valid=True,
policy_id=model.id, policy_id=model.id,
version=model.version, version=model.version,
scene_name=model.scene_name, scene_name=model.scene_name,
) )
@@ -9,11 +9,11 @@ from app.services.generation.billing_service import build_credit_biz_key
class LlmBillingConfigurationError(RuntimeError): class LlmBillingConfigurationError(RuntimeError):
"""LLM 场景固定预扣配置无效。""" """LLM 场景积分配置无效。"""
class LlmBillingStateError(RuntimeError): class LlmBillingStateError(RuntimeError):
"""业务 attempt 的固定预扣状态不允许继续执行。""" """业务 attempt 的 LLM 积分消费状态不允许继续执行。"""
class LlmProviderPostprocessError(RuntimeError): class LlmProviderPostprocessError(RuntimeError):
@@ -26,7 +26,7 @@ class LlmProviderPostprocessError(RuntimeError):
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class LlmBillingPolicy: class LlmBillingPolicy:
pre_deduct_credits: float charge_credits: float
valid: bool = True valid: bool = True
error: str | None = None error: str | None = None
policy_id: str | None = None policy_id: str | None = None
@@ -65,13 +65,15 @@ class LlmBillingContext:
current_call_attempt_id: str | None = None current_call_attempt_id: str | None = None
@property @property
def pre_deduct_biz_key(self) -> str: def charge_biz_key(self) -> str:
return build_credit_biz_key( return build_credit_biz_key(
owner_type=self.owner_type, owner_type=self.owner_type,
owner_id=self.owner_id, owner_id=self.owner_id,
attempt_no=self.attempt_no, attempt_no=self.attempt_no,
charge_kind=self.charge_kind, charge_kind=self.charge_kind,
action="pre_deduct", # 使用独立 llm_charge 幂等键,避免与历史 Token 按量计费的 :charge 流水碰撞;
# CreditRecord 的业务动作仍然记录为 charge(真实消费)。
action="llm_charge",
) )
@property @property
@@ -86,11 +88,11 @@ class LlmBillingContext:
@property @property
def billing_biz_key(self) -> str: def billing_biz_key(self) -> str:
return self.pre_deduct_biz_key return self.charge_biz_key
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class LlmPreDeductResult: class LlmChargeResult:
amount: float amount: float
state: LlmBillingLedgerState state: LlmBillingLedgerState
created: bool = False created: bool = False
@@ -100,10 +102,10 @@ class LlmPreDeductResult:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class LlmPreDeductValidation: class LlmChargeValidation:
can_execute: bool can_execute: bool
amount: float amount: float
state: LlmBillingLedgerState state: LlmBillingLedgerState
reason: str | None = None reason: str | None = None
pre_deduct_record_id: str | None = None charge_record_id: str | None = None
execution_id: str | None = None execution_id: str | None = None
@@ -1,12 +1,32 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import func, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.llm_billing import LlmBillingExecutionStatus
from app.models.llm_billing.call_attempt import LlmCallAttempt from app.models.llm_billing.call_attempt import LlmCallAttempt
from app.models.llm_billing.execution import LlmBillingExecution from app.models.llm_billing.execution import LlmBillingExecution
def _normalized_execution_status(status: str) -> str:
if status == LlmBillingExecutionStatus.PRE_DEDUCTED.value:
return LlmBillingExecutionStatus.CHARGED.value
return status
def _execution_status_filter(status: str):
# 新接口只暴露 charged;查询时同时兼容历史 pre_deducted 数据。
if status in {
LlmBillingExecutionStatus.CHARGED.value,
LlmBillingExecutionStatus.PRE_DEDUCTED.value,
}:
return or_(
LlmBillingExecution.status == LlmBillingExecutionStatus.CHARGED.value,
LlmBillingExecution.status == LlmBillingExecutionStatus.PRE_DEDUCTED.value,
)
return LlmBillingExecution.status == status
async def list_executions_with_calls( async def list_executions_with_calls(
db: AsyncSession, db: AsyncSession,
*, *,
@@ -20,9 +40,10 @@ async def list_executions_with_calls(
if scene_code: if scene_code:
filters.append(LlmBillingExecution.scene_code == scene_code) filters.append(LlmBillingExecution.scene_code == scene_code)
if status: if status:
filters.append(LlmBillingExecution.status == status) filters.append(_execution_status_filter(status))
if user_id: if user_id:
filters.append(LlmBillingExecution.user_id == user_id) filters.append(LlmBillingExecution.user_id == user_id)
total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters)) total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters))
total = int(total_result.scalar_one() or 0) total = int(total_result.scalar_one() or 0)
result = await db.execute( result = await db.execute(
@@ -33,22 +54,29 @@ async def list_executions_with_calls(
.limit(page_size) .limit(page_size)
) )
executions = list(result.scalars().all()) executions = list(result.scalars().all())
ids = [item.id for item in executions]
calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in ids} # 先收集 execution ids,再一次批查所有 CallAttempt 并按 execution_id 回填,避免 N+1。
if ids: execution_ids = [item.id for item in executions]
calls_by_execution: dict[str, list[LlmCallAttempt]] = {item_id: [] for item_id in execution_ids}
if execution_ids:
call_result = await db.execute( call_result = await db.execute(
select(LlmCallAttempt) select(LlmCallAttempt)
.where(LlmCallAttempt.billing_execution_id.in_(ids)) .where(LlmCallAttempt.billing_execution_id.in_(execution_ids))
.order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence) .order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence)
) )
for call in call_result.scalars().all(): for call in call_result.scalars().all():
calls_by_execution.setdefault(call.billing_execution_id, []).append(call) calls_by_execution.setdefault(call.billing_execution_id, []).append(call)
items = []
items: list[dict] = []
for execution in executions: for execution in executions:
# 数据库物理字段 pre_deduct_credits 为历史命名;Admin/API 统一只暴露 charge_credits。
data = { data = {
column.name: getattr(execution, column.name) column.name: getattr(execution, column.name)
for column in LlmBillingExecution.__table__.columns for column in LlmBillingExecution.__table__.columns
if column.name != "pre_deduct_credits"
} }
data["charge_credits"] = execution.pre_deduct_credits
data["status"] = _normalized_execution_status(execution.status)
data["calls"] = calls_by_execution.get(execution.id, []) data["calls"] = calls_by_execution.get(execution.id, [])
items.append(data) items.append(data)
return items, total return items, total
+151 -75
View File
@@ -1,14 +1,15 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import replace
from typing import Any, Iterable, Mapping from typing import Any, Iterable, Mapping
from sqlalchemy import and_, or_, select from sqlalchemy import select, tuple_
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_BILLING_SCENE_LABELS, CREDIT_RECORD_BILLING_SCENE_LABELS,
CreditRecordAction, CreditRecordAction,
CreditRecordChargeKind,
CreditRecordSubject, CreditRecordSubject,
) )
from app.enums.llm_billing import ( from app.enums.llm_billing import (
@@ -37,18 +38,19 @@ from app.services.llm_billing.context import (
LlmBillingContext, LlmBillingContext,
LlmBillingStateError, LlmBillingStateError,
LlmProviderPostprocessError, LlmProviderPostprocessError,
LlmPreDeductResult, LlmChargeResult,
LlmPreDeductValidation, LlmChargeValidation,
) )
from app.services.operation_log_service import log_operation_event from app.services.operation_log_service import log_operation_event
from app.utils.exceptions import InsufficientCreditsError
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@dataclass(slots=True)
class _ExecutionState: def _credit_subject(ctx: LlmBillingContext) -> str:
execution: LlmBillingExecution | None if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
state: LlmBillingLedgerState return CreditRecordSubject.ANALYSIS.value
reason: str | None = None return CreditRecordSubject.TEXT.value
def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str: def _scene_name(ctx: LlmBillingContext, policy_name: str | None = None) -> str:
@@ -122,13 +124,21 @@ async def _select_fixed_model(db: AsyncSession, ctx: LlmBillingContext) -> None:
async def _find_execution(db: AsyncSession, ctx: LlmBillingContext, *, for_update: bool = False) -> LlmBillingExecution | None: async def _find_execution(db: AsyncSession, ctx: LlmBillingContext, *, for_update: bool = False) -> LlmBillingExecution | None:
stmt = select(LlmBillingExecution).where( stmt = (
select(LlmBillingExecution)
.where(
LlmBillingExecution.user_id == ctx.user_id, LlmBillingExecution.user_id == ctx.user_id,
LlmBillingExecution.scene_code == (ctx.billing_scene or ""), LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
LlmBillingExecution.owner_type == ctx.owner_type, LlmBillingExecution.owner_type == ctx.owner_type,
LlmBillingExecution.owner_id == ctx.owner_id, LlmBillingExecution.owner_id == ctx.owner_id,
LlmBillingExecution.business_attempt_no == ctx.attempt_no, LlmBillingExecution.business_attempt_no == ctx.attempt_no,
).limit(1) )
.limit(1)
# CallAttempt/TokenUsage 使用独立短事务更新 execution 聚合值;主 Session
# 可能在 commit 前后仍持有同一对象。强制回填数据库最新标量,避免
# expire_on_commit=False 下 identity map 返回旧 Token/调用次数。
.execution_options(populate_existing=True)
)
if for_update: if for_update:
stmt = stmt.with_for_update() stmt = stmt.with_for_update()
result = await db.execute(stmt) result = await db.execute(stmt)
@@ -139,6 +149,7 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed
if execution is None: if execution is None:
return LlmBillingLedgerState.MISSING return LlmBillingLedgerState.MISSING
mapping = { mapping = {
LlmBillingExecutionStatus.CHARGED.value: LlmBillingLedgerState.ACTIVE,
LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE, LlmBillingExecutionStatus.PRE_DEDUCTED.value: LlmBillingLedgerState.ACTIVE,
LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE, LlmBillingExecutionStatus.PROCESSING.value: LlmBillingLedgerState.ACTIVE,
LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED, LlmBillingExecutionStatus.SUCCEEDED.value: LlmBillingLedgerState.SUCCEEDED,
@@ -149,13 +160,15 @@ def _state_for_execution(execution: LlmBillingExecution | None) -> LlmBillingLed
return mapping.get(execution.status, LlmBillingLedgerState.INVALID) return mapping.get(execution.status, LlmBillingLedgerState.INVALID)
async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductResult: async def charge_llm_credits(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeResult:
"""在业务 API 事务内同步足额预扣固定场景积分。""" """在业务 API 事务内按后台场景配置同步真实消费积分。"""
_log(ctx, LlmBillingEvent.PRE_DEDUCT_START, status="started") _log(ctx, LlmBillingEvent.CHARGE_START, status="started")
if not ctx.billing_scene: if not ctx.billing_scene:
raise LlmBillingConfigurationError("LLM固定预扣必须指定billing_scene") _log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error="billing_scene_missing")
# 幂等执行记录查询必须位于用户积分事务锁之后。否则两个相同 API 请求 raise LlmBillingConfigurationError("LLM积分消费必须指定billing_scene")
# 可能都在锁前读到 missing,第二个虽不会重复扣分,却会撞执行记录唯一键。
# 同一 user + scene + owner + attempt 的幂等判断必须位于用户积分锁之后,
# 避免并发 API 请求同时看到 missing 后重复消费或撞 execution 唯一键。
await acquire_user_credit_lock(db, ctx.user_id) await acquire_user_credit_lock(db, ctx.user_id)
existing = await _find_execution(db, ctx, for_update=True) existing = await _find_execution(db, ctx, for_update=True)
if existing: if existing:
@@ -163,22 +176,29 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
ctx.model_config_id = existing.model_config_id ctx.model_config_id = existing.model_config_id
ctx.model_name = existing.model_name_snapshot ctx.model_name = existing.model_name_snapshot
ctx.provider = existing.provider_snapshot ctx.provider = existing.provider_snapshot
return LlmPreDeductResult( ctx.model_parameters_snapshot = existing.model_parameters_snapshot
state = _state_for_execution(existing)
_log(
ctx,
LlmBillingEvent.CHARGE_IDEMPOTENT_HIT,
amount=to_float(existing.pre_deduct_credits), amount=to_float(existing.pre_deduct_credits),
state=_state_for_execution(existing), credit_record_id=existing.credit_record_id,
execution_status=existing.status,
ledger_state=state.value,
)
return LlmChargeResult(
amount=to_float(existing.pre_deduct_credits),
state=state,
created=False, created=False,
record_id=existing.credit_record_id, record_id=existing.credit_record_id,
execution_id=existing.id, execution_id=existing.id,
) )
await _select_fixed_model(db, ctx) await _select_fixed_model(db, ctx)
policy = await get_llm_billing_policy( policy = await get_llm_billing_policy(db, scene_code=ctx.billing_scene)
db,
scene_code=ctx.billing_scene,
)
if not policy.valid: if not policy.valid:
_log(ctx, LlmBillingEvent.PRE_DEDUCT_CONFIG_INVALID, status="failed", error=policy.error) _log(ctx, LlmBillingEvent.CHARGE_CONFIG_INVALID, status="failed", error=policy.error)
raise LlmBillingConfigurationError(policy.error or "LLM固定预扣配置无效") raise LlmBillingConfigurationError(policy.error or "LLM场景积分配置无效")
scene_name = _scene_name(ctx, policy.scene_name) scene_name = _scene_name(ctx, policy.scene_name)
meta = { meta = {
@@ -186,8 +206,8 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
"owner_id": ctx.owner_id, "owner_id": ctx.owner_id,
"attempt_no": ctx.attempt_no, "attempt_no": ctx.attempt_no,
"charge_kind": ctx.charge_kind, "charge_kind": ctx.charge_kind,
"charge_action": CreditRecordAction.PRE_DEDUCT.value, "charge_action": CreditRecordAction.CHARGE.value,
"credit_subject": CreditRecordSubject.TEXT.value, "credit_subject": _credit_subject(ctx),
"billing_scene": ctx.billing_scene, "billing_scene": ctx.billing_scene,
"scene_name_snapshot": scene_name, "scene_name_snapshot": scene_name,
"source_module": ctx.source_module, "source_module": ctx.source_module,
@@ -199,19 +219,31 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
"engine_provider": ctx.provider, "engine_provider": ctx.provider,
"engine_model_name": ctx.model_name, "engine_model_name": ctx.model_name,
} }
try:
mutation = await deduct_credits_result( mutation = await deduct_credits_result(
db, db,
user_id=ctx.user_id, user_id=ctx.user_id,
amount=policy.pre_deduct_credits, amount=policy.charge_credits,
description=f"{scene_name}固定预扣积分", description=f"{scene_name}积分消费",
related_id=ctx.related_id or ctx.owner_id, related_id=ctx.related_id or ctx.owner_id,
biz_key=ctx.pre_deduct_biz_key, biz_key=ctx.charge_biz_key,
record_meta=meta, record_meta=meta,
record_type="consume", record_type="consume",
request_time=ctx.request_time or utc_now(), request_time=ctx.request_time or utc_now(),
) )
except InsufficientCreditsError:
_log(
ctx,
LlmBillingEvent.CHARGE_INSUFFICIENT,
status="failed",
error="insufficient_credits",
amount=policy.charge_credits,
)
raise
if mutation.record is None: if mutation.record is None:
raise LlmBillingStateError("LLM固定预扣流水创建失败") raise LlmBillingStateError("LLM积分消费流水创建失败")
execution = LlmBillingExecution( execution = LlmBillingExecution(
id=generate_id(), id=generate_id(),
user_id=ctx.user_id, user_id=ctx.user_id,
@@ -227,18 +259,27 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
billing_policy_id=policy.policy_id, billing_policy_id=policy.policy_id,
billing_policy_version=policy.version, billing_policy_version=policy.version,
request_time=ctx.request_time or utc_now(), request_time=ctx.request_time or utc_now(),
pre_deduct_credits=policy.pre_deduct_credits, # 数据库物理字段仍沿用历史 pre_deduct_credits 命名;语义已经是本 attempt 真实消费积分。
pre_deduct_credits=policy.charge_credits,
credit_record_id=mutation.record.id, credit_record_id=mutation.record.id,
status=LlmBillingExecutionStatus.PRE_DEDUCTED.value, status=LlmBillingExecutionStatus.CHARGED.value,
) )
db.add(execution) db.add(execution)
await db.flush() await db.flush()
# flush 后只使用已加载标量,不访问 relationship,避免 commit 后懒加载/MissingGreenlet 风险。
mutation.record.llm_billing_execution_id = execution.id mutation.record.llm_billing_execution_id = execution.id
mutation.record.scene_name_snapshot = scene_name mutation.record.scene_name_snapshot = scene_name
ctx.billing_execution_id = execution.id ctx.billing_execution_id = execution.id
_log(ctx, LlmBillingEvent.PRE_DEDUCT_SUCCESS, amount=policy.pre_deduct_credits, credit_record_id=mutation.record.id) _log(
return LlmPreDeductResult( ctx,
amount=policy.pre_deduct_credits, LlmBillingEvent.CHARGE_SUCCESS,
amount=policy.charge_credits,
credit_record_id=mutation.record.id,
created=mutation.created,
)
return LlmChargeResult(
amount=policy.charge_credits,
state=LlmBillingLedgerState.ACTIVE, state=LlmBillingLedgerState.ACTIVE,
created=mutation.created, created=mutation.created,
record_id=mutation.record.id, record_id=mutation.record.id,
@@ -246,10 +287,18 @@ async def pre_deduct(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductRe
) )
async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation: async def ensure_llm_charged(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation:
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started")
execution = await _find_execution(db, ctx) execution = await _find_execution(db, ctx)
if execution is None: if execution is None:
return LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "pre_deduct_missing") _log(
ctx,
LlmBillingEvent.EXECUTION_BLOCKED,
status="failed",
error="charge_missing",
ledger_state=LlmBillingLedgerState.MISSING.value,
)
return LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "charge_missing")
ctx.billing_execution_id = execution.id ctx.billing_execution_id = execution.id
ctx.model_config_id = execution.model_config_id ctx.model_config_id = execution.model_config_id
ctx.model_name = execution.model_name_snapshot ctx.model_name = execution.model_name_snapshot
@@ -261,68 +310,73 @@ async def ensure_pre_deducted(db: AsyncSession, ctx: LlmBillingContext) -> LlmPr
_log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status) _log(ctx, LlmBillingEvent.EXECUTION_BLOCKED, status="failed", execution_status=execution.status)
else: else:
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status) _log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS, execution_status=execution.status)
return LlmPreDeductValidation( return LlmChargeValidation(
can_execute=can_execute, can_execute=can_execute,
amount=to_float(execution.pre_deduct_credits), amount=to_float(execution.pre_deduct_credits),
state=state, state=state,
reason=None if can_execute else execution.status, reason=None if can_execute else execution.status,
pre_deduct_record_id=execution.credit_record_id, charge_record_id=execution.credit_record_id,
execution_id=execution.id, execution_id=execution.id,
) )
async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmPreDeductValidation]: async def get_llm_ledger_states(db: AsyncSession, contexts: Iterable[LlmBillingContext]) -> dict[str, LlmChargeValidation]:
items = list(contexts) items = list(contexts)
if not items: if not items:
return {} return {}
conditions = [
and_( keys = list(dict.fromkeys(
LlmBillingExecution.user_id == ctx.user_id, (ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no)
LlmBillingExecution.scene_code == (ctx.billing_scene or ""),
LlmBillingExecution.owner_type == ctx.owner_type,
LlmBillingExecution.owner_id == ctx.owner_id,
LlmBillingExecution.business_attempt_no == ctx.attempt_no,
)
for ctx in items for ctx in items
] ))
result = await db.execute(select(LlmBillingExecution).where(or_(*conditions))) result = await db.execute(
select(LlmBillingExecution).where(
tuple_(
LlmBillingExecution.user_id,
LlmBillingExecution.scene_code,
LlmBillingExecution.owner_type,
LlmBillingExecution.owner_id,
LlmBillingExecution.business_attempt_no,
).in_(keys)
)
)
by_key = { by_key = {
(row.user_id, row.scene_code, row.owner_type, row.owner_id, row.business_attempt_no): row (row.user_id, row.scene_code, row.owner_type, row.owner_id, row.business_attempt_no): row
for row in result.scalars().all() for row in result.scalars().all()
} }
output: dict[str, LlmPreDeductValidation] = {} output: dict[str, LlmChargeValidation] = {}
for ctx in items: for ctx in items:
row = by_key.get((ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no)) row = by_key.get((ctx.user_id, ctx.billing_scene or "", ctx.owner_type, ctx.owner_id, ctx.attempt_no))
state = _state_for_execution(row) state = _state_for_execution(row)
output[ctx.pre_deduct_biz_key] = LlmPreDeductValidation( output[ctx.charge_biz_key] = LlmChargeValidation(
can_execute=state == LlmBillingLedgerState.ACTIVE, can_execute=state == LlmBillingLedgerState.ACTIVE,
amount=to_float(row.pre_deduct_credits) if row else 0.0, amount=to_float(row.pre_deduct_credits) if row else 0.0,
state=state, state=state,
reason=None if state == LlmBillingLedgerState.ACTIVE else (row.status if row else "missing"), reason=None if state == LlmBillingLedgerState.ACTIVE else (row.status if row else "missing"),
pre_deduct_record_id=row.credit_record_id if row else None, charge_record_id=row.credit_record_id if row else None,
execution_id=row.id if row else None, execution_id=row.id if row else None,
) )
return output return output
async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmPreDeductValidation: async def validate_retryable_previous_attempt(db: AsyncSession, ctx: LlmBillingContext) -> LlmChargeValidation:
previous = replace(ctx, attempt_no=max(1, ctx.attempt_no - 1)) if ctx.attempt_no > 1 else ctx previous = replace(ctx, attempt_no=max(1, ctx.attempt_no - 1)) if ctx.attempt_no > 1 else ctx
validation = (await get_llm_ledger_states(db, [previous]))[previous.pre_deduct_biz_key] validation = (await get_llm_ledger_states(db, [previous]))[previous.charge_biz_key]
if ctx.attempt_no <= 1: if ctx.attempt_no <= 1:
return LlmPreDeductValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt") return LlmChargeValidation(True, 0.0, LlmBillingLedgerState.MISSING, "first_attempt")
allowed = validation.state in { allowed = validation.state in {
LlmBillingLedgerState.SUCCEEDED, LlmBillingLedgerState.SUCCEEDED,
LlmBillingLedgerState.REFUNDED, LlmBillingLedgerState.REFUNDED,
LlmBillingLedgerState.FINAL_FAILED, LlmBillingLedgerState.FINAL_FAILED,
} }
return LlmPreDeductValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.pre_deduct_record_id, validation.execution_id) return LlmChargeValidation(allowed, validation.amount, validation.state, None if allowed else "previous_attempt_not_final", validation.charge_record_id, validation.execution_id)
async def log_provider_start(db: AsyncSession, ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None: async def log_provider_start(db: AsyncSession, ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
if not ctx.billing_execution_id: if not ctx.billing_execution_id:
validation = await ensure_pre_deducted(db, ctx) validation = await ensure_llm_charged(db, ctx)
if not validation.can_execute: if not validation.can_execute:
raise LlmBillingStateError("缺少有效固定预扣,禁止调用LLM") raise LlmBillingStateError("缺少有效LLM积分消费记录,禁止调用LLM")
await create_call_attempt(ctx, detail=detail) await create_call_attempt(ctx, detail=detail)
_log(ctx, LlmBillingEvent.PROVIDER_START, status="started", call_attempt_id=ctx.current_call_attempt_id, detail=dict(detail or {})) _log(ctx, LlmBillingEvent.PROVIDER_START, status="started", call_attempt_id=ctx.current_call_attempt_id, detail=dict(detail or {}))
@@ -401,7 +455,7 @@ async def mark_business_success(
await finish_call_success(ctx, usage=usage) await finish_call_success(ctx, usage=usage)
execution = await _find_execution(db, ctx, for_update=True) execution = await _find_execution(db, ctx, for_update=True)
if execution is None: if execution is None:
raise LlmBillingStateError("LLM固定预扣执行记录不存在") raise LlmBillingStateError("LLM积分消费执行记录不存在")
ctx.billing_execution_id = execution.id ctx.billing_execution_id = execution.id
created = execution.status != LlmBillingExecutionStatus.SUCCEEDED.value created = execution.status != LlmBillingExecutionStatus.SUCCEEDED.value
if execution.status in {LlmBillingExecutionStatus.REFUNDED.value, LlmBillingExecutionStatus.FINAL_FAILED.value}: if execution.status in {LlmBillingExecutionStatus.REFUNDED.value, LlmBillingExecutionStatus.FINAL_FAILED.value}:
@@ -427,7 +481,7 @@ async def mark_business_success(
record.llm_call_count = execution.total_call_count record.llm_call_count = execution.total_call_count
record.llm_success_call_count = execution.successful_call_count record.llm_success_call_count = execution.successful_call_count
record.llm_failed_call_count = execution.failed_call_count record.llm_failed_call_count = execution.failed_call_count
_log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, pre_deduct_credits=to_float(execution.pre_deduct_credits), idempotent=not created) _log(ctx, LlmBillingEvent.BUSINESS_SUCCESS, charge_credits=to_float(execution.pre_deduct_credits), idempotent=not created)
return BillingSummary( return BillingSummary(
record_id=ctx.owner_id, record_id=ctx.owner_id,
user_id=ctx.user_id, user_id=ctx.user_id,
@@ -447,7 +501,7 @@ async def mark_business_success(
async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary: async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
from app.services.credit.ledger_service import refund_consumption from app.services.credit.ledger_service import refund_consumption
# 与固定预扣保持 user advisory -> execution -> balance 的统一锁顺序。 # 与真实消费保持 user advisory -> execution -> original record -> balance 的统一锁顺序。
await acquire_user_credit_lock(db, ctx.user_id) await acquire_user_credit_lock(db, ctx.user_id)
execution = await _find_execution(db, ctx, for_update=True) execution = await _find_execution(db, ctx, for_update=True)
if execution is None: if execution is None:
@@ -459,21 +513,47 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
ctx.user_id, ctx.user_id,
[BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)], [BillingItem(ctx.charge_kind, to_float(execution.pre_deduct_credits), False, "already_refunded", ctx.refund_biz_key, ctx.attempt_no)],
) )
if execution.status == LlmBillingExecutionStatus.SUCCEEDED.value:
# 调用方只有在业务最终失败时才能进这里;允许从业务成功调用记录转最终失败退款。 original_result = await db.execute(
pass select(CreditRecord)
.where(CreditRecord.id == execution.credit_record_id, CreditRecord.user_id == ctx.user_id)
.limit(1)
.with_for_update()
)
original_record = original_result.scalar_one_or_none()
if original_record is None or not original_record.biz_key:
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
execution.final_error_message = "原LLM积分消费流水不存在或缺少BizKey"
execution.completed_at = utc_now()
_log(
ctx,
LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED,
status="failed",
error=execution.final_error_message,
credit_record_id=execution.credit_record_id,
)
raise LlmBillingStateError(execution.final_error_message)
execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value execution.status = LlmBillingExecutionStatus.FINAL_FAILED.value
execution.final_error_message = str(error or "业务最终失败")[:5000] execution.final_error_message = str(error or "业务最终失败")[:5000]
execution.completed_at = utc_now() execution.completed_at = utc_now()
_log(ctx, LlmBillingEvent.FINAL_FAILURE_START, status="started", error=error) original_biz_key = original_record.biz_key
_log(
ctx,
LlmBillingEvent.FINAL_FAILURE_START,
status="started",
error=error,
credit_record_id=original_record.id,
original_biz_key=original_biz_key,
)
try: try:
refunded = await refund_consumption( refunded = await refund_consumption(
db, db,
user_id=ctx.user_id, user_id=ctx.user_id,
refund_for_biz_key=ctx.pre_deduct_biz_key, refund_for_biz_key=original_biz_key,
description=f"{execution.scene_name_snapshot}最终失败", description=f"{execution.scene_name_snapshot}最终失败",
related_id=ctx.related_id or ctx.owner_id, related_id=ctx.related_id or ctx.owner_id,
biz_key=f"{ctx.pre_deduct_biz_key}:business-final-refund", biz_key=f"{original_biz_key}:business-final-refund",
record_meta={ record_meta={
"owner_type": ctx.owner_type, "owner_type": ctx.owner_type,
"owner_id": ctx.owner_id, "owner_id": ctx.owner_id,
@@ -500,6 +580,7 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS, LlmBillingEvent.FINAL_FAILURE_REFUND_SUCCESS,
refund_available=to_float(refunded.available_amount), refund_available=to_float(refunded.available_amount),
refund_expired=to_float(refunded.expired_amount), refund_expired=to_float(refunded.expired_amount),
credit_record_id=original_record.id,
) )
return BillingSummary( return BillingSummary(
ctx.owner_id, ctx.owner_id,
@@ -508,15 +589,10 @@ async def refund_on_final_failure(db: AsyncSession, ctx: LlmBillingContext, *, e
) )
except Exception as exc: except Exception as exc:
execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value execution.status = LlmBillingExecutionStatus.REFUND_FAILED.value
_log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc)) _log(ctx, LlmBillingEvent.FINAL_FAILURE_REFUND_FAILED, status="failed", error=str(exc), credit_record_id=original_record.id)
raise raise
async def refund_pre_deduct(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
summary = await refund_on_final_failure(db, ctx, error=reason)
return summary.items[0] if summary.items else BillingItem(ctx.charge_kind, 0.0, False, "missing", ctx.refund_biz_key, ctx.attempt_no)
async def finalize_llm_business_failure( async def finalize_llm_business_failure(
ctx: LlmBillingContext, ctx: LlmBillingContext,
*, *,
@@ -38,7 +38,7 @@ 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, LlmPreDeductValidation, get_llm_ledger_states from app.services.llm_billing import LlmBillingContext, LlmChargeValidation, get_llm_ledger_states
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")
@@ -100,24 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
async def _load_step_billing_validations( async def _load_step_billing_validations(
db: AsyncSession, db: AsyncSession,
steps: Iterable[ModuleGenerationStep], steps: Iterable[ModuleGenerationStep],
) -> dict[str, LlmPreDeductValidation]: ) -> dict[str, LlmChargeValidation]:
step_list = list(steps) step_list = list(steps)
if not step_list: if not step_list:
return {} return {}
contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list} contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list}
# 固定预扣已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录, # 场景积分消费已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
# 不再读取旧 SystemConfig 冻结配置;缺少固定预扣记录时直接终止恢复。 # 不再读取旧 SystemConfig 冻结配置;缺少场景积分消费记录时直接终止恢复。
ledger_states = await get_llm_ledger_states(db, contexts.values()) ledger_states = await get_llm_ledger_states(db, contexts.values())
output: dict[str, LlmPreDeductValidation] = {} output: dict[str, LlmChargeValidation] = {}
for step_id, ctx in contexts.items(): for step_id, ctx in contexts.items():
ledger = ledger_states.get( ledger = ledger_states.get(
ctx.pre_deduct_biz_key, ctx.charge_biz_key,
LlmPreDeductValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"), LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
) )
if ledger.state == LlmBillingLedgerState.ACTIVE: if ledger.state == LlmBillingLedgerState.ACTIVE:
output[step_id] = ledger output[step_id] = ledger
elif ledger.state == LlmBillingLedgerState.MISSING: elif ledger.state == LlmBillingLedgerState.MISSING:
output[step_id] = LlmPreDeductValidation( output[step_id] = LlmChargeValidation(
False, False,
0.0, 0.0,
LlmBillingLedgerState.INVALID, LlmBillingLedgerState.INVALID,
@@ -50,12 +50,12 @@ from app.services.generation.pipeline.db_lock_service import (
from app.services.generation.task_factory_service import create_chat_generation_task_for_module from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.llm_billing import ( from app.services.llm_billing import (
LlmBillingContext, LlmBillingContext,
ensure_pre_deducted, ensure_llm_charged,
log_provider_failure, log_provider_failure,
log_provider_start, log_provider_start,
log_provider_success, log_provider_success,
mark_business_success, mark_business_success,
pre_deduct, charge_llm_credits,
record_provider_exception, record_provider_exception,
finalize_llm_business_failure, finalize_llm_business_failure,
) )
@@ -432,7 +432,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
build_v2_video_prompt_billing_context( build_v2_video_prompt_billing_context(
user_id=str(current_user.id), user_id=str(current_user.id),
@@ -599,7 +599,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
build_v2_video_prompt_billing_context( build_v2_video_prompt_billing_context(
user_id=str(current_user.id), user_id=str(current_user.id),
@@ -723,7 +723,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
build_v2_video_prompt_billing_context( build_v2_video_prompt_billing_context(
user_id=str(project.user_id), user_id=str(project.user_id),
@@ -887,10 +887,10 @@ async def run_video_prompt_optimize_v2(
description_prefix=f"{config.display_name}视频提词优化", description_prefix=f"{config.display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}", trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = utc_now() step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message project.error_message = step.error_message
@@ -58,7 +58,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt from app.services.llm import optimize_prompt
from app.services.llm_billing import ( from app.services.llm_billing import (
LlmBillingContext, LlmBillingContext,
ensure_pre_deducted, ensure_llm_charged,
log_provider_failure, log_provider_failure,
record_provider_exception, record_provider_exception,
log_provider_start, log_provider_start,
@@ -66,7 +66,7 @@ from app.services.llm_billing import (
refund_on_final_failure, refund_on_final_failure,
finalize_llm_business_failure, finalize_llm_business_failure,
mark_business_success, mark_business_success,
pre_deduct, charge_llm_credits,
) )
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,
@@ -842,7 +842,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
LlmBillingContext( LlmBillingContext(
user_id=str(project.user_id), user_id=str(project.user_id),
@@ -957,10 +957,10 @@ async def run_image_prompt_optimize(
description_prefix="拆镜复刻图片AI提词优化", description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"shot-image-prompt:{step_id_value}", trace_id=f"shot-image-prompt:{step_id_value}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now() step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message project.error_message = step.error_message
@@ -1286,7 +1286,7 @@ 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 pre_deduct( await charge_llm_credits(
db, db,
LlmBillingContext( LlmBillingContext(
user_id=str(project.user_id), user_id=str(project.user_id),
@@ -1399,10 +1399,10 @@ async def run_video_prompt_optimize(
description_prefix="拆镜复刻视频AI提词优化", description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"shot-video-prompt:{step_id_value}", trace_id=f"shot-video-prompt:{step_id_value}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止任务" step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now() step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message project.error_message = step.error_message
@@ -233,7 +233,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
*(build_task_set_analysis_billing_context(item) for item in task_sets), *(build_task_set_analysis_billing_context(item) for item in task_sets),
*(build_segment_analysis_billing_context(item) for item in segments), *(build_segment_analysis_billing_context(item) for item in segments),
] ]
# 固定预扣配置变更后仍需识别并继续处理已存在的有效预扣;缺少预扣记录时禁止恢复。 # 场景积分消费配置变更后仍需识别并继续处理已存在的有效消费;缺少消费记录时禁止恢复。
billing_states = await get_llm_ledger_states(db, billing_contexts) billing_states = await get_llm_ledger_states(db, billing_contexts)
lock_keys: list[str] = [] lock_keys: list[str] = []
@@ -259,7 +259,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1 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) context = build_task_set_analysis_billing_context(item)
validation = billing_states.get(context.pre_deduct_biz_key) validation = billing_states.get(context.charge_biz_key)
can_execute = bool(validation and validation.can_execute) can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
if validation and validation.state == LlmBillingLedgerState.MISSING: if validation and validation.state == LlmBillingLedgerState.MISSING:
@@ -289,7 +289,7 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1 results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1
continue continue
context = build_segment_analysis_billing_context(item) context = build_segment_analysis_billing_context(item)
validation = billing_states.get(context.pre_deduct_biz_key) validation = billing_states.get(context.charge_biz_key)
can_execute = bool(validation and validation.can_execute) can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
if validation and validation.state == LlmBillingLedgerState.MISSING: if validation and validation.state == LlmBillingLedgerState.MISSING:
@@ -56,7 +56,7 @@ from app.services.module_generation_log_service import log_module_event_file
from app.services.llm_billing import ( from app.services.llm_billing import (
LlmBillingContext, LlmBillingContext,
refund_on_final_failure, refund_on_final_failure,
pre_deduct, charge_llm_credits,
validate_retryable_previous_attempt, validate_retryable_previous_attempt,
) )
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
@@ -244,7 +244,7 @@ async def create_task_set(
) )
existing = existing_result.scalar_one_or_none() existing = existing_result.scalar_one_or_none()
if existing: if existing:
# 幂等命中只返回已有任务,不重复预扣、绑定资源或投递 Celery。 # 幂等命中只返回已有任务,不重复消费积分、绑定资源或投递 Celery。
return existing, False 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)
@@ -265,7 +265,7 @@ async def create_task_set(
) )
db.add(task_set) db.add(task_set)
await db.flush() await db.flush()
await pre_deduct(db, build_task_set_analysis_billing_context(task_set)) await charge_llm_credits(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",
@@ -683,7 +683,7 @@ 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,也不重复预扣 # 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复消费积分
# 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。 # 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。
log_module_event_file( log_module_event_file(
module=MODULE, module=MODULE,
@@ -748,7 +748,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 pre_deduct(db, build_segment_analysis_billing_context(segment)) await charge_llm_credits(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(
@@ -920,7 +920,7 @@ async def delete_segment(
await refund_on_final_failure( await refund_on_final_failure(
db, db,
build_segment_analysis_billing_context(segment), build_segment_analysis_billing_context(segment),
error="用户删除自定义拆镜片段,释放未结算的片段分析预扣", error="用户删除自定义拆镜片段,退回片段分析消费积分",
) )
deleted_at = _now() deleted_at = _now()
@@ -979,7 +979,7 @@ async def delete_segment(
"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",
"media_refund": False, "media_refund": False,
"llm_pre_deduct_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value, "llm_charge_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
}, },
) )
@@ -1037,14 +1037,14 @@ async def delete_task_set(
await refund_on_final_failure( await refund_on_final_failure(
db, db,
build_task_set_analysis_billing_context(task_set), build_task_set_analysis_billing_context(task_set),
error="用户删除拆镜任务集,释放未结算的原视频分析预扣", error="用户删除拆镜任务集,退回原视频分析消费积分",
) )
for segment in segments: for segment in segments:
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value: if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
await refund_on_final_failure( await refund_on_final_failure(
db, db,
build_segment_analysis_billing_context(segment), build_segment_analysis_billing_context(segment),
error="用户删除拆镜任务集,释放未结算的片段分析预扣", error="用户删除拆镜任务集,退回片段分析消费积分",
) )
segment_ids = [segment.id for segment in segments] segment_ids = [segment.id for segment in segments]
@@ -1110,7 +1110,7 @@ async def delete_task_set(
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"}, "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",
"media_refund": False, "media_refund": False,
"llm_pre_deduct_refund_on_cancel": True, "llm_charge_refund_on_cancel": True,
}, },
) )
return ShotTaskSetDeleteOut( return ShotTaskSetDeleteOut(
@@ -1195,7 +1195,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 pre_deduct(db, build_task_set_analysis_billing_context(task_set)) await charge_llm_credits(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,
@@ -1301,7 +1301,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 pre_deduct(db, build_segment_analysis_billing_context(segment)) await charge_llm_credits(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,
@@ -1420,7 +1420,7 @@ async def mark_custom_segment_split_dispatch_failed(
segment.split_next_retry_at = None segment.split_next_retry_at = None
segment.split_last_error = error_message segment.split_last_error = error_message
# 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用 # 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用
# 原固定预扣;只有片段分析最终失败或用户删除片段时才按原来源退款。 # 原场景积分消费;只有片段分析最终失败或用户删除片段时才按原来源退款。
log_module_event_file( log_module_event_file(
module=MODULE, module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value, event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -39,7 +39,7 @@ from app.services.shot_replicate_taskset_service import (
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.llm_billing import ( from app.services.llm_billing import (
LlmBillingContext, LlmBillingContext,
ensure_pre_deducted, ensure_llm_charged,
log_provider_failure, log_provider_failure,
log_provider_start, log_provider_start,
log_provider_success, log_provider_success,
@@ -474,9 +474,9 @@ async def _run_analyze_original_video(task_set_id: str, expected_attempt_no: int
description_prefix="拆镜复刻原视频分析", description_prefix="拆镜复刻原视频分析",
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}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止原视频分析任务" error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止原视频分析任务"
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
@@ -746,9 +746,9 @@ async def _run_analyze_custom_segment_video(segment_id: str, expected_attempt_no
description_prefix="拆镜复刻片段视频分析", description_prefix="拆镜复刻片段视频分析",
trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}", trace_id=f"shot-segment-analysis:{segment_id}:attempt:{attempt_no}",
) )
pre_deduct_validation = await ensure_pre_deducted(db, llm_billing_context) charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not pre_deduct_validation.can_execute: if not charge_validation.can_execute:
error_message = f"LLM账务状态异常({pre_deduct_validation.state.value}),已终止片段视频分析任务" error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止片段视频分析任务"
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
@@ -957,7 +957,7 @@ async def _mark_auto_segment_analysis_dispatch_failed(
project_id=task_set_id, project_id=task_set_id,
step_id=segment_id, step_id=segment_id,
user_id=user_id, user_id=user_id,
message="自定义切片分析任务投递失败,已标记分析失败并退回固定预扣积分", message="自定义切片分析任务投递失败,已标记分析失败并退回场景消费积分",
detail={ detail={
"segment_id": segment_id, "segment_id": segment_id,
"task_set_id": task_set_id, "task_set_id": task_set_id,
@@ -1216,7 +1216,7 @@ 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
# 切片最终失败不退回片段分析固定预扣;手动切片重试继续沿用 # 切片最终失败不退回片段分析场景积分消费;手动切片重试继续沿用
# 原 attempt。用户最终删除片段/任务集时再做取消补偿。 # 原 attempt。用户最终删除片段/任务集时再做取消补偿。
else: else:
segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value segment.split_status = ShotSplitStatusEnum.RETRY_WAITING.value
+1 -1
View File
@@ -207,7 +207,7 @@ export async function mockOptimizePrompt(
await delay(1500); await delay(1500);
const project = MOCK_PROJECTS.find((p) => p.id === projectId); const project = MOCK_PROJECTS.find((p) => p.id === projectId);
// Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。 // Mock 与正式接口保持一致:提示词优化按后台场景配置直接消费积分,积分不足同步拦截。
const cost = 5; const cost = 5;
if (currentUser) { if (currentUser) {
+1 -1
View File
@@ -3200,7 +3200,7 @@ const GeneratePage: React.FC = () => {
</span> </span>
)} )}
<Tooltip <Tooltip
title="固定预扣积分由服务端按当前功能场景校验" title="LLM积分由服务端按当前功能场景直接消费"
placement="top" placement="top"
> >
<Button <Button
+1 -1
View File
@@ -1667,7 +1667,7 @@ function InitialInfo() {
</Button> </Button>
{isV2 && ( {isV2 && (
<Tooltip <Tooltip
title="固定预扣积分由服务端按当前功能场景校验" title="LLM积分由服务端按当前功能场景直接消费"
placement="top" placement="top"
> >
<Button <Button
@@ -1639,7 +1639,7 @@ const GenerateConver: React.FC = () => {
{/* 立即生成按钮 */} {/* 立即生成按钮 */}
<Tooltip <Tooltip
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM固定预扣由服务端按场景校验'} title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM积分由服务端按场景配置直接消费'}
placement="top" placement="top"
> >
<Button <Button
+1 -1
View File
@@ -1683,7 +1683,7 @@ function RemoveInfo() {
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}> <div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
<Tooltip <Tooltip
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM固定预扣由服务端按场景校验'} title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM积分由服务端按场景配置直接消费'}
placement="top" placement="top"
> >
<Button <Button
+2 -2
View File
@@ -1011,7 +1011,7 @@ function InitialInfo() {
{!isV2 && ( {!isV2 && (
<Tooltip <Tooltip
title="固定预扣积分由服务端按当前功能场景校验" title="LLM积分由服务端按当前功能场景直接消费"
placement="top" placement="top"
> >
<Button <Button
@@ -1570,7 +1570,7 @@ function InitialInfo() {
</Button> </Button>
{isV2 && ( {isV2 && (
<Tooltip <Tooltip
title="固定预扣积分由服务端按当前功能场景校验" title="LLM积分由服务端按当前功能场景直接消费"
placement="top" placement="top"
> >
<Button <Button