积分消耗明细改版V1
This commit is contained in:
@@ -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];
|
||||||
|
|||||||
@@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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_release(type=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()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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: "升级积分转入",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -3200,7 +3200,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title="固定预扣积分由服务端按当前功能场景校验"
|
title="LLM积分由服务端按当前功能场景直接消费"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user