This commit is contained in:
2026-08-13 09:38:36 +08:00
124 changed files with 11236 additions and 5549 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-D-7FxsJd.js"></script>
<script type="module" crossorigin src="/assets/index-DDKKooWp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
</head>
<body>
+6 -2
View File
@@ -21,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminCreditProducts from './pages/AdminCreditProducts';
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
@@ -98,7 +100,9 @@ const App = () => {
<Route path="image-engines" element={<AdminImageEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="credit-products" element={<AdminCreditProducts />} />
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
+84 -1
View File
@@ -17,7 +17,7 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
} from '../types';
import type {
@@ -1267,3 +1267,86 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
}
// ── Dynamic Credit Products ───────────────────────────────
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
return {
...raw,
renewalEnabled: raw.renewalEnabled === true,
tierRank: raw.tierRank == null ? null : Number(raw.tierRank),
monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
firstPurchasePrice: Number(raw.firstPurchasePrice || 0),
regularPrice: Number(raw.regularPrice || 0),
activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
price: Number(raw.price || 0),
grantCredits: Number(raw.grantCredits || 0),
sortOrder: Number(raw.sortOrder || 0),
isActive: raw.isActive === true,
};
}
export async function getCreditProducts(productType?: 'subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : '';
const products = await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`);
return products.map(normalizeCreditProduct);
}
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> {
return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload));
}
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> {
return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload));
}
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> {
return normalizeCreditProduct(await api.put<CreditProduct>(
`/admin/credit-management/products/${id}/renewal`,
{ renewal_enabled: renewalEnabled },
));
}
export async function disableCreditProduct(id: string): Promise<void> {
await api.delete(`/admin/credit-management/products/${id}`);
}
export async function getAdminUserCreditSummary(userId: string): Promise<any> {
return api.get(`/admin/credit-management/users/${userId}/summary`);
}
export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise<any[]> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (status) params.set('status', status);
return api.get(`/admin/credit-management/users/${userId}/balances?${params.toString()}`);
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/grant`, payload);
}
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/deduct`, payload);
}
// ── LLM 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies');
}
export async function createLlmBillingPolicy(payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.post('/admin/llm-billing/policies', payload);
}
export async function updateLlmBillingPolicy(id: string, payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.put(`/admin/llm-billing/policies/${id}`, payload);
}
export async function getLlmBillingExecutions(params?: { page?: number; pageSize?: number; sceneCode?: string; status?: string; userId?: string }): Promise<{ items: import('../types').LlmBillingExecution[]; total: number }> {
const query = new URLSearchParams();
query.set('page', String(params?.page || 1));
query.set('page_size', String(params?.pageSize || 20));
if (params?.sceneCode) query.set('scene_code', params.sceneCode);
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('user_id', params.userId);
return api.get(`/admin/llm-billing/executions?${query.toString()}`);
}
@@ -0,0 +1,397 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import { EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
const formatMoney = (value?: number | null): string => {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<CreditProduct | null>(null);
const [open, setOpen] = useState(false);
const [renewalEnabled, setRenewalEnabled] = useState(true);
const [form] = Form.useForm();
const replaceItem = (product: CreditProduct) => {
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
};
const load = async () => {
setLoading(true);
try {
setItems(await getCreditProducts(type));
} catch (error: any) {
message.error(error?.message || '加载积分商品失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [type]);
const openEditor = (item?: CreditProduct) => {
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
setEditing(item || null);
setRenewalEnabled(nextRenewalEnabled);
setOpen(true);
form.resetFields();
if (item) {
form.setFieldsValue({
productCode: item.productCode,
name: item.name,
description: item.description || undefined,
featuresText: (item.features || []).join('\n'),
tierCode: item.tierCode,
tierRank: item.tierRank,
billingCycle: item.billingCycle,
monthlyGrantCredits: item.monthlyGrantCredits,
firstPurchasePrice: item.firstPurchasePrice,
regularPrice: item.regularPrice,
activityPrice: item.activityPrice,
activityRange: item.activityStartAt && item.activityEndAt
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
: undefined,
renewalEnabled: nextRenewalEnabled,
price: item.price,
grantCredits: item.grantCredits,
validityMonths: item.validityMonths ?? 1,
sortOrder: item.sortOrder,
isActive: item.isActive,
});
return;
}
form.setFieldsValue({
productCode: undefined,
name: undefined,
description: undefined,
featuresText: undefined,
isActive: true,
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: nextRenewalEnabled,
validityMonths: 1,
});
};
const save = async () => {
try {
const values = await form.validateFields();
const activityRange = values.activityRange || [];
const payload: Record<string, unknown> = {
product_code: values.productCode,
product_type: type,
name: values.name,
description: values.description || null,
features: String(values.featuresText || '')
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (type === 'subscription') {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
monthly_grant_credits: values.monthlyGrantCredits,
first_purchase_price: values.firstPurchasePrice,
regular_price: values.regularPrice,
activity_price: values.activityPrice ?? null,
activity_start_at: activityRange[0]?.toISOString() || null,
activity_end_at: activityRange[1]?.toISOString() || null,
renewal_enabled: renewalEnabled,
price: values.regularPrice,
});
} else {
Object.assign(payload, {
price: values.price,
grant_credits: values.grantCredits,
validity_months: values.validityMonths,
});
}
const savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (type === 'subscription' && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
if (editing) {
replaceItem(savedProduct);
} else {
setItems((current) => [...current, savedProduct].sort((a, b) => {
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
}));
}
message.success(editing ? '商品已更新' : '商品已创建');
setOpen(false);
setEditing(null);
setRenewalEnabled(true);
form.resetFields();
} catch (error: any) {
if (error?.errorFields) return;
message.error(error?.message || '保存失败');
}
};
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
const previous = product.renewalEnabled === true;
const optimistic = { ...product, renewalEnabled: enabled };
replaceItem(optimistic);
if (editing?.id === product.id) {
setEditing(optimistic);
setRenewalEnabled(enabled);
form.setFieldValue('renewalEnabled', enabled);
}
try {
const savedProduct = await setCreditProductRenewal(product.id, enabled);
if (savedProduct.renewalEnabled !== enabled) {
throw new Error('续费开关保存结果与提交值不一致');
}
replaceItem(savedProduct);
if (editing?.id === product.id) {
setEditing(savedProduct);
setRenewalEnabled(savedProduct.renewalEnabled === true);
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
}
message.success(enabled ? '已开启续费' : '已关闭续费');
} catch (error: any) {
const reverted = { ...product, renewalEnabled: previous };
replaceItem(reverted);
if (editing?.id === product.id) {
setEditing(reverted);
setRenewalEnabled(previous);
form.setFieldValue('renewalEnabled', previous);
}
message.error(error?.message || '更新续费状态失败');
}
};
const columns = useMemo(() => type === 'subscription' ? [
{
title: '套餐',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{
title: '等级',
key: 'tier',
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
},
{
title: '周期',
dataIndex: 'billingCycle',
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
},
{
title: '每月积分',
dataIndex: 'monthlyGrantCredits',
render: (value?: number) => Number(value || 0).toLocaleString(),
},
{
title: '首充价',
dataIndex: 'firstPurchasePrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '原价',
dataIndex: 'regularPrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '活动价',
key: 'activity',
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
? '-'
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
},
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => (
<Switch
checked={enabled === true}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => void toggleRenewal(row, checked)}
/>
),
},
{
title: '状态',
dataIndex: 'isActive',
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title="确认下架该商品?"
onConfirm={async () => {
await disableCreditProduct(row.id);
message.success('已下架');
await load();
}}
>
<Button type="link" danger icon={<StopOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
title: '增值包',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{ title: '状态', dataIndex: 'isActive', render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag> },
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title="确认下架该商品?"
onConfirm={async () => {
await disableCreditProduct(row.id);
message.success('已下架');
await load();
}}
>
<Button type="link" danger icon={<StopOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items]);
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
{type === 'subscription' ? '新增订阅套餐' : '新增积分增值包'}
</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
<Modal
open={open}
title={editing ? '编辑积分商品' : '新增积分商品'}
onOk={save}
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
width={760}
destroyOnClose
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="productCode" label="商品编码" rules={[{ required: true }]}>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input style={{ width: 260 }} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{type === 'subscription' ? <>
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
{ value: 'quarterly', label: '季' },
{ value: 'yearly', label: '年' },
]}
/>
</Form.Item>
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16}>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去首订资格的用户不会在客户端看到该套餐,也不能通过接口续费或升级购买。"
>
<Switch
checked={renewalEnabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => {
setRenewalEnabled(checked);
form.setFieldValue('renewalEnabled', checked);
}}
/>
</Form.Item>
</Space>
</> : <Space align="start" size={16}>
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
<Select
style={{ width: 140 }}
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
/>
</Form.Item>
</Space>}
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminCreditProducts;
@@ -3,12 +3,11 @@ import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
deleteCreditRatio, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
@@ -54,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, sysConfigs, enginesData] = await Promise.all([
const [ratioData, enginesData] = await Promise.all([
getCreditRatios(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
@@ -81,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
setRatios(ratioData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
setTextRateConfig({ id: textCfg.id });
}
} catch {
message.error('加载积分比例失败');
} finally {
@@ -171,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
}
};
const handleSaveTextRate = async () => {
if (!textRateConfig) return;
setSavingTextRate(true);
try {
await updateSystemConfig(textRateConfig.id, String(textRate));
message.success('文字积分费率已更新');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSavingTextRate(false);
}
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
@@ -338,40 +316,6 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
ceil(token数 x / 1000)1
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<Space.Compact style={{ width: 160 }}>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: '100%' }}
/>
<Typography.Text></Typography.Text>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1000 token = {textRate} 500 token = {(500 * textRate / 1000).toFixed(4)}
</Typography.Text>
</div>
</Card>
{/* Generation Credit Ratios */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
@@ -40,15 +40,18 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
};
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
charge: { text: '真实费', color: 'red' },
charge: { text: '真实费', color: 'red' },
refund: { text: '真实退款', color: 'blue' },
hold: { text: '预扣占用', color: 'gold' },
hold_release: { text: '预扣释放', color: 'green' },
pre_deduct: { text: '历史固定预扣', color: 'default' },
hold: { text: '历史预扣占用', color: 'gold' },
hold_release: { text: '历史预扣释放', color: 'green' },
};
const userScopeOptions = [
@@ -63,6 +66,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'expire', label: '过期' },
{ value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -101,10 +106,11 @@ const chargeKindOptions = [
const chargeActionOptions = [
{ value: '', label: '全部交易动作' },
{ value: 'charge', label: '真实费' },
{ value: 'charge', label: '真实费' },
{ value: 'refund', label: '真实退款' },
{ value: 'hold', label: '预扣占用' },
{ value: 'hold_release', label: '预扣释放' },
{ value: 'pre_deduct', label: '历史固定预扣' },
{ value: 'hold', label: '历史预扣占用' },
{ value: 'hold_release', label: '历史预扣释放' },
];
const sourceModuleOptions = [
@@ -285,10 +291,12 @@ const AdminCreditRecords: React.FC = () => {
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
{ title: '计费场景', maxWidth: 32, render: (r) => r.sceneNameSnapshot || r.billingSceneLabel || '-' },
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0}` },
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
@@ -300,6 +308,7 @@ const AdminCreditRecords: React.FC = () => {
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
{ title: '积分来源分摊', minWidth: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceTypeLabel || a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -315,22 +324,18 @@ const AdminCreditRecords: React.FC = () => {
],
summaryRows: [
['总充值', exportSummary.totalRecharge],
['总消费(真实扣费 + 预扣占用)', exportSummary.totalConsume],
[' · 真实扣费(独立统计:type=消费 & action=charge/NULL', exportSummary.totalCharge],
[' · 预扣占用(独立统计:type=消费 & action=hold', exportSummary.totalHold],
['总回退(真实退款 + 预扣释放)', exportSummary.totalRefund],
[' · 真实退款(独立统计:type=回退 & action=refund/NULL', exportSummary.totalRefundReal],
[' · 预扣释放(独立统计:type=回退 & action=hold_release', exportSummary.totalHoldRelease],
['净消耗(总消费 − 总回退,≥ 0)', exportSummary.netConsume],
['总真实消费', exportSummary.totalConsume],
['总真实退款', exportSummary.totalRefund],
['净消耗(总消费 总退款,≥ 0', exportSummary.netConsume],
['交易笔数', exportSummary.transactionCount],
['生成条数', exportSummary.generationCount],
['生成尝试次数', exportSummary.generationAttemptCount],
['图片生成条数', exportSummary.imageGenerationCount],
['视频生成条数', exportSummary.videoGenerationCount],
['图片消费积分(仅真实费)', exportSummary.imageConsume],
['视频消费积分(仅真实费)', exportSummary.videoConsume],
['提词消费积分(仅真实费)', exportSummary.textConsume],
['视频分析积分(仅真实费)', exportSummary.analysisConsume],
['图片消费积分(仅真实费)', exportSummary.imageConsume],
['视频消费积分(仅真实费)', exportSummary.videoConsume],
['提词消费积分(仅真实费)', exportSummary.textConsume],
['视频分析积分(仅真实费)', exportSummary.analysisConsume],
['总 Token', exportSummary.totalTokens],
['输入 Token', exportSummary.inputTokens],
['输出 Token', exportSummary.outputTokens],
@@ -355,10 +360,12 @@ const AdminCreditRecords: React.FC = () => {
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.sceneNameSnapshot || r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} <div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.llmSuccessCallCount || 0)} / {n(r.llmFailedCallCount || 0)}</div></div> },
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}> {n(r.inputTokens)} / {n(r.outputTokens)}</div></div> },
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
@@ -380,32 +387,18 @@ const AdminCreditRecords: React.FC = () => {
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}> + </span>
</div>
<div style={{ color: '#94a3b8' }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
<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 style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}></div>
</div>
</Space>
</Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
<div style={{ minWidth: 0 }}>
<div style={{ color: '#94a3b8' }}>
退
<span style={{ marginLeft: 6, fontSize: 10, color: '#94a3b8' }}>退 + </span>
</div>
<div style={{ color: '#94a3b8' }}>退</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>
退 <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 style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>退</div>
</div>
</Space>
</Card>
@@ -414,7 +407,7 @@ const AdminCreditRecords: React.FC = () => {
<div>
<div style={{ color: '#6366f1' }}></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>
</Space>
</Card>
@@ -480,7 +473,27 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2160 }}
expandable={{
rowExpandable: (record) => Boolean(record.allocations?.length),
expandedRowRender: (record) => (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
{ title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
]}
scroll={{ x: 1070 }}
/>
),
}}
scroll={{ x: 2520 }}
/>
</Card>
</div>
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { getLlmBillingExecutions } from '../api';
import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
charged: { label: '已消费', color: 'blue' },
processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' },
refunded: { label: '已退款', color: 'purple' },
refund_failed: { label: '退款失败', color: 'red' },
};
const AdminLlmBillingExecutions: React.FC = () => {
const [items, setItems] = useState<LlmBillingExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>();
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
useEffect(() => { load(); }, [page, status]);
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
<Timeline items={(record.calls || []).map((call) => ({
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
children: <div>
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)}  {call.callSequence} {call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
<div>{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
<div>{call.modelNameSnapshot || '-'} {call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
{call.tokenUnavailableReason
? <div>Token{call.tokenUnavailableReason}</div>
: <div> Token{call.inputTokens ?? 0}  Token{call.outputTokens ?? 0}  Token{call.totalTokens ?? 0}</div>}
{call.errorMessage && <Typography.Text type="danger">{call.errorMessage}</Typography.Text>}
{call.postprocessStatus === 'succeeded' && <div></div>}
{call.postprocessError && <div>{call.postprocessError}</div>}
</div>,
}))} />
<Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '消费积分', children: record.chargeCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
]} />
</div> }} columns={[
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
{ title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ 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: 'requestTime', render: (v: string) => formatDate(v) },
]} />
</Card>;
};
export default AdminLlmBillingExecutions;
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
import type { LlmBillingPolicy } from '../types';
const SCENES: Array<{ value: string; label: string }> = [
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
];
const AdminLlmBillingPolicies: React.FC = () => {
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
setItems(await getLlmBillingPolicies());
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => {
setEditing(item || null);
setOpen(true);
form.resetFields();
form.setFieldsValue(item
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
: { is_active: true, charge_credits: 5 });
};
const save = async () => {
try {
const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values);
else await createLlmBillingPolicy(values);
message.success('已保存');
setOpen(false);
load();
} catch (error: any) {
if (!error?.errorFields) message.error(error?.message || '保存失败');
}
};
return <Card
title={<Space><RobotOutlined />LLM积分配置</Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}
>
<Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' },
{ title: '消费积分', dataIndex: 'chargeCredits' },
{ title: '版本', dataIndex: 'version' },
{ 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> },
]} />
<Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
<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>
</Modal>
</Card>;
};
export default AdminLlmBillingPolicies;
+2 -4
View File
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
return <Tag color="blue">{labelMap[v] || v}</Tag>;
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
@@ -1,253 +1 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
interface PackageItem {
id: string;
name: string;
credits: number;
price: number;
bonusCredits: number;
totalCredits: number;
description: string | null;
packageType: string;
isGift: boolean;
isActive: boolean;
sortOrder: number;
}
const TYPE_COLORS: Record<string, string> = {
normal: 'blue',
gift: 'green',
promo: 'purple',
};
const TYPE_LABELS: Record<string, string> = {
normal: '常规',
gift: '赠送',
promo: '促销',
};
const AdminRechargePackages: React.FC = () => {
const [packages, setPackages] = useState<PackageItem[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getRechargePackages();
setPackages(data.map((item: any) => ({
id: item.id,
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
description: item.description,
packageType: item.package_type ?? item.packageType ?? 'normal',
isGift: item.is_gift ?? item.isGift ?? false,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
})));
} catch {
message.error('加载充值套餐失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
credits: values.credits,
price: values.price,
bonus_credits: values.bonusCredits || 0,
description: values.description || null,
package_type: values.packageType || 'normal',
is_gift: values.isGift || false,
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (modal.item?.id) {
await saveRechargePackage({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveRechargePackage(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
try {
await deleteRechargePackage(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: PackageItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonusCredits,
description: item.description,
packageType: item.packageType,
isGift: item.isGift,
isActive: item.isActive,
sortOrder: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
}
};
const columns = [
{
title: '套餐名称', key: 'name', width: 160,
render: (_: any, r: PackageItem) => (
<div>
<Typography.Text strong>{r.name}</Typography.Text>
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
</div>
),
},
{
title: '基础积分', dataIndex: 'credits', width: 100,
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
},
{
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
render: (v: number) => v > 0
? <Tag color="green">+{v.toLocaleString()}</Tag>
: <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '总积分', key: 'total', width: 100,
render: (_: any, r: PackageItem) => (
<Typography.Text strong style={{ color: '#6366f1' }}>
{(r.credits + r.bonusCredits).toLocaleString()}
</Typography.Text>
),
},
{
title: '价格(元)', dataIndex: 'price', width: 100,
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'packageType', width: 80,
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: PackageItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{packages.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={packages}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
<Input placeholder="例如:进阶包" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 'normal', label: '常规' },
{ value: 'gift', label: '赠送' },
{ value: 'promo', label: '促销' },
]} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input placeholder="套餐描述(可选)" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminRechargePackages;
export { default } from './AdminCreditProducts';
+2 -107
View File
@@ -48,14 +48,6 @@ const AdminSettings: React.FC = () => {
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// LLM 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -70,83 +62,14 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
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);
const llmManagedKeys = new Set([
'optimize_hold_credits',
'llm_billing_enabled',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
]);
for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
if (value === undefined || value === null || value === '') return;
const normalizedValue = String(value);
const existing = configs.find(c => c.key === key);
if (existing) {
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
} else {
await createSystemConfig(key, normalizedValue, description);
}
};
const enabledConfig = [
'llm_billing_enabled',
values.llm_billing_enabled,
'是否启用 LLM 统一预扣与真实扣费结算',
] as const;
const llmHoldConfigs = [
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
] as const;
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
if (!llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
for (const [key, value, description] of llmHoldConfigs) {
await saveManagedConfig(key, value, description);
}
if (llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -257,7 +180,6 @@ const AdminSettings: React.FC = () => {
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => c.key === 'operation_manual'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -273,13 +195,6 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
@@ -409,7 +324,7 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
@@ -470,26 +385,6 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
</Form.Item>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
>
<Input type="number" min={0} placeholder="默认5" size="large" />
</Form.Item>
{[
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
].map(([name, label, extra]) => (
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
</Form.Item>
))}
</div>
</Form>
),
+141 -37
View File
@@ -6,12 +6,15 @@ import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
deleteUserResourceCapacity,
getAdminUsers,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -66,6 +69,12 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -164,12 +173,44 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.description);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch { /* validation */ }
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -493,10 +534,20 @@ const AdminUsers: React.FC = () => {
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -685,50 +736,103 @@ const AdminUsers: React.FC = () => {
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
<Form form={form} layout="vertical" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="description" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong></Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ 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: '积分等级', 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: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || v || '-'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</Modal>
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
@@ -893,7 +997,7 @@ const AdminUsers: React.FC = () => {
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="credits" label="初始积分" initialValue={0}>
<Form.Item name="credits" label="初始积分(一个自然月有效)" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
+128 -10
View File
@@ -868,17 +868,17 @@ export interface VideoPromptSchemaPreviewOut {
export interface AdminCreditRecordSummary {
totalRecharge: number;
/** 总消费(仅 type=consume,不含团队内部转账)= 真实扣费 + 预扣占用 */
/** 总消费:仅真实消费,不含历史 hold/团队内部转账;兼容历史 LLM pre_deduct 真实消费 */
totalConsume: number;
/** 总回退(仅 type=refund)= 真实退款 + 预扣释放 */
/** 总退款:仅真实退款,不含历史 hold_release */
totalRefund: number;
/** 独立统计列:真实扣费 charge(含历史 NULL),对应"筛选类型=消费 & action=charge/NULL"求和 */
/** 兼容字段:与 totalConsume 同真实消费口径 */
totalCharge: number;
/** 独立统计列:预扣占用 hold */
/** 历史兼容字段:hold 占用,不计入 totalConsume */
totalHold: number;
/** 独立统计列:真实退款 refund(含历史 NULL) */
/** 兼容字段:与 totalRefund 同真实退款口径 */
totalRefundReal: number;
/** 独立统计列:预扣释放 hold_releasetype=refund, action=hold_release */
/** 历史兼容字段:hold_release,不计入 totalRefund */
totalHoldRelease: number;
/** 净消耗 = max(totalConsume - totalRefund, 0),即真正"用掉了"的积分 */
netConsume: number;
@@ -887,19 +887,39 @@ export interface AdminCreditRecordSummary {
generationAttemptCount: number;
imageGenerationCount: number;
videoGenerationCount: number;
/** 子分类消费(图片)仅真实费 charge 口径 */
/** 子分类消费(图片)仅真实费 charge 口径 */
imageConsume: number;
/** 子分类消费(视频)仅真实费 charge 口径 */
/** 子分类消费(视频)仅真实费 charge 口径 */
videoConsume: number;
/** 子分类消费(提词)仅真实费 charge 口径 */
/** 子分类消费(提词)仅真实费 charge 口径 */
textConsume: number;
/** 子分类消费(分析)仅真实费 charge 口径 */
/** 子分类消费(分析)仅真实费 charge 口径 */
analysisConsume: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
}
export interface AdminCreditRecordAllocation {
id: string;
creditBalanceId: string;
sourceAllocationId?: string | null;
allocationAction: string;
allocationActionLabel?: string;
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
validFrom?: string;
expiresAt?: string;
unspentBefore?: number;
unspentAfter?: number;
consumedBefore?: number;
consumedAfter?: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -917,6 +937,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
balanceDelta?: number;
expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -936,6 +958,12 @@ export interface AdminCreditRecord {
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sceneNameSnapshot?: string;
requestTime?: string;
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -1443,6 +1471,96 @@ export interface VideoUpscaleConfigSavePayload {
};
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'credit_addon';
export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
export interface CreditProduct {
id: string;
productCode: string;
productType: CreditProductType;
name: string;
description?: string | null;
features?: string[];
tierCode?: string | null;
tierRank?: number | null;
billingCycle?: SubscriptionBillingCycle | null;
monthlyGrantCredits?: number;
grantCount?: number;
firstPurchasePrice?: number;
regularPrice?: number;
activityPrice?: number | null;
activityStartAt?: string | null;
activityEndAt?: string | null;
renewalEnabled: boolean;
price: number;
grantCredits?: number;
validityMonths?: number | null;
creditLevel: 'promotional' | 'general';
currency: string;
isActive: boolean;
sortOrder: number;
}
export interface LlmBillingPolicy {
id: string;
sceneCode: string;
sceneName: string;
chargeCredits: number;
isActive: boolean;
version: number;
createdBy?: string | null;
updatedBy?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface LlmCallAttempt {
id: string;
callSequence: number;
retrySequence: number;
modelConfigId?: string | null;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
providerRequestId?: string | null;
requestStartedAt: string;
responseReceivedAt?: string | null;
durationMs?: number | null;
status: string;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
httpStatus?: number | null;
errorMessage?: string | null;
tokenUnavailableReason?: string | null;
postprocessStatus?: string | null;
postprocessError?: string | null;
}
export interface LlmBillingExecution {
id: string;
userId: string;
sceneCode: string;
sceneNameSnapshot: string;
ownerType: string;
ownerId: string;
businessAttemptNo: number;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
requestTime: string;
chargeCredits: number;
status: string;
totalCallCount: number;
successfulCallCount: number;
failedCallCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
refundAvailableCredits: number;
refundExpiredCredits: number;
finalErrorMessage?: string | null;
calls: LlmCallAttempt[];
}
// ── Invoice Types ───────────────────────────────────────
export interface InvoiceItem {
+20
View File
@@ -36,3 +36,23 @@ export function formatDate(iso: string | null | undefined): string {
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function formatDatePrecise(iso: string | null | undefined): string {
if (!iso) return '-';
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
const s = iso.trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
let target = utcMs;
if (tz === 'Z') target += CST_OFFSET * 60000;
else if (tz) {
const sign = tz[0] === '+' ? 1 : -1;
const compact = tz.slice(1).replace(':', '');
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
}
const d = new Date(target);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -0,0 +1,25 @@
"""merge changes from remote
Revision ID: 1a4d1f095fa1
Revises: 20260811_20260811, 8a6d2f4c9b10
Create Date: 2026-08-11 10:30:10.431672
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1a4d1f095fa1'
down_revision: Union[str, None] = ('20260811_20260811', '8a6d2f4c9b10')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,650 @@
"""add credit product renewal enabled
Revision ID: 8a6d2f4c9b10
Revises: 7f3c8a2d9e41
Create Date: 2026-08-06 17:20:00
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from alembic import op
import sqlalchemy as sa
revision = "8a6d2f4c9b10"
down_revision = "7f3c8a2d9e41"
branch_labels = None
depends_on = None
_PRODUCT_UPSERT_SQL = sa.text(
"""
INSERT INTO credit_products (
id,
product_code,
product_type,
name,
description,
features_json,
tier_code,
tier_rank,
billing_cycle,
monthly_grant_credits,
first_purchase_price,
regular_price,
activity_price,
activity_start_at,
activity_end_at,
grant_credits,
validity_months,
price,
credit_level,
currency,
is_active,
sort_order,
created_at,
updated_at,
renewal_enabled
) VALUES (
:id,
:product_code,
:product_type,
:name,
:description,
CAST(:features_json AS JSON),
:tier_code,
:tier_rank,
:billing_cycle,
:monthly_grant_credits,
:first_purchase_price,
:regular_price,
:activity_price,
:activity_start_at,
:activity_end_at,
:grant_credits,
:validity_months,
:price,
:credit_level,
:currency,
:is_active,
:sort_order,
:created_at,
:updated_at,
:renewal_enabled
)
ON CONFLICT (product_code) DO UPDATE SET
product_type = EXCLUDED.product_type,
name = EXCLUDED.name,
description = EXCLUDED.description,
features_json = EXCLUDED.features_json,
tier_code = EXCLUDED.tier_code,
tier_rank = EXCLUDED.tier_rank,
billing_cycle = EXCLUDED.billing_cycle,
monthly_grant_credits = EXCLUDED.monthly_grant_credits,
first_purchase_price = EXCLUDED.first_purchase_price,
regular_price = EXCLUDED.regular_price,
activity_price = EXCLUDED.activity_price,
activity_start_at = EXCLUDED.activity_start_at,
activity_end_at = EXCLUDED.activity_end_at,
grant_credits = EXCLUDED.grant_credits,
validity_months = EXCLUDED.validity_months,
price = EXCLUDED.price,
credit_level = EXCLUDED.credit_level,
currency = EXCLUDED.currency,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order,
updated_at = EXCLUDED.updated_at,
renewal_enabled = EXCLUDED.renewal_enabled
"""
)
_POLICY_UPSERT_SQL = sa.text(
"""
INSERT INTO llm_billing_policies (
id,
scene_code,
scene_name,
pre_deduct_credits,
is_active,
version,
created_by,
updated_by,
created_at,
updated_at
) VALUES (
:id,
:scene_code,
:scene_name,
:pre_deduct_credits,
:is_active,
:version,
:created_by,
:updated_by,
:created_at,
:updated_at
)
ON CONFLICT (scene_code) DO UPDATE SET
scene_name = EXCLUDED.scene_name,
pre_deduct_credits = EXCLUDED.pre_deduct_credits,
is_active = EXCLUDED.is_active,
version = EXCLUDED.version,
updated_by = EXCLUDED.updated_by,
updated_at = EXCLUDED.updated_at
"""
)
def _dt(value: str) -> datetime:
return datetime.fromisoformat(value)
def _seed_credit_products() -> None:
rows = [
{
"id": "0019fd653e4680767ab",
"product_code": "1",
"product_type": "subscription",
"name": "入门",
"description": None,
"features_json": "[]",
"tier_code": "入门",
"tier_rank": 1,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("1000.00"),
"first_purchase_price": Decimal("79.00"),
"regular_price": Decimal("122.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("122.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T09:07:30.020043+08:00"),
"updated_at": _dt("2026-08-06T09:52:15.579827+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd67e8a786dda0e",
"product_code": "2",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("2000.00"),
"first_purchase_price": Decimal("154.00"),
"regular_price": Decimal("239.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("239.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"updated_at": _dt("2026-08-06T09:54:05.049302+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd67f4c8bf75dd1",
"product_code": "3",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("3000.00"),
"first_purchase_price": Decimal("221.00"),
"regular_price": Decimal("353.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("353.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T09:54:54.768868+08:00"),
"updated_at": _dt("2026-08-06T09:55:10.408471+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd680784c16dd15",
"product_code": "4",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "monthly",
"monthly_grant_credits": Decimal("4000.00"),
"first_purchase_price": Decimal("280.00"),
"regular_price": Decimal("476.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("476.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"updated_at": _dt("2026-08-06T09:56:11.442596+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68270abc3dcbd",
"product_code": "5",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("5000.00"),
"first_purchase_price": Decimal("1029.00"),
"regular_price": Decimal("1499.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1499.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 5,
"created_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"updated_at": _dt("2026-08-06T09:58:20.601505+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68373d24dbab0",
"product_code": "6",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("6000.00"),
"first_purchase_price": Decimal("1197.00"),
"regular_price": Decimal("1791.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("1791.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 6,
"created_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"updated_at": _dt("2026-08-06T09:59:26.972669+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd68469706aa522",
"product_code": "7",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "quarterly",
"monthly_grant_credits": Decimal("7000.00"),
"first_purchase_price": Decimal("1367.00"),
"regular_price": Decimal("2079.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("2079.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 7,
"created_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"updated_at": _dt("2026-08-06T10:00:29.843562+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd6904f72f59505",
"product_code": "8",
"product_type": "subscription",
"name": "标准",
"description": None,
"features_json": "[]",
"tier_code": "标准",
"tier_rank": 2,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("10000.00"),
"first_purchase_price": Decimal("7308.00"),
"regular_price": Decimal("8399.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("8399.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 8,
"created_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"updated_at": _dt("2026-08-06T10:13:29.614018+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd692bc5e42b8be",
"product_code": "9",
"product_type": "subscription",
"name": "高级",
"description": None,
"features_json": "[]",
"tier_code": "高级",
"tier_rank": 3,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("15000.00"),
"first_purchase_price": Decimal("10710.00"),
"regular_price": Decimal("12586.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("12586.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 9,
"created_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"updated_at": _dt("2026-08-06T10:16:08.545547+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69417fabda8c6",
"product_code": "10",
"product_type": "subscription",
"name": "超级",
"description": None,
"features_json": "[]",
"tier_code": "超级",
"tier_rank": 4,
"billing_cycle": "yearly",
"monthly_grant_credits": Decimal("20000.00"),
"first_purchase_price": Decimal("13440.00"),
"regular_price": Decimal("16685.00"),
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": None,
"validity_months": None,
"price": Decimal("16685.00"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 10,
"created_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"updated_at": _dt("2026-08-06T10:17:37.548293+08:00"),
"renewal_enabled": True,
},
{
"id": "0019fd69a07e363c7b5",
"product_code": "ZLB-A",
"product_type": "credit_addon",
"name": "增量包A",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("500.00"),
"validity_months": 1,
"price": Decimal("0.01"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 1,
"created_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"updated_at": _dt("2026-08-06T10:24:06.646673+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69a640d7ccc92",
"product_code": "ZLB-B",
"product_type": "credit_addon",
"name": "增量包B",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1000.00"),
"validity_months": 1,
"price": Decimal("0.02"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 2,
"created_at": _dt("2026-08-06T10:24:30.256294+08:00"),
"updated_at": _dt("2026-08-06T10:24:35.878389+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69ae039d3ba69",
"product_code": "ZLB-C",
"product_type": "credit_addon",
"name": "增量包C",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("1500.00"),
"validity_months": 1,
"price": Decimal("0.03"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 3,
"created_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"updated_at": _dt("2026-08-06T10:25:02.043620+08:00"),
"renewal_enabled": False,
},
{
"id": "0019fd69b3166a6a71c",
"product_code": "ZLB-D",
"product_type": "credit_addon",
"name": "增量包D",
"description": None,
"features_json": "[]",
"tier_code": None,
"tier_rank": None,
"billing_cycle": None,
"monthly_grant_credits": None,
"first_purchase_price": None,
"regular_price": None,
"activity_price": None,
"activity_start_at": None,
"activity_end_at": None,
"grant_credits": Decimal("2000.00"),
"validity_months": 1,
"price": Decimal("0.04"),
"credit_level": "general",
"currency": "CNY",
"is_active": True,
"sort_order": 4,
"created_at": _dt("2026-08-06T10:25:22.811498+08:00"),
"updated_at": _dt("2026-08-06T10:25:29.647103+08:00"),
"renewal_enabled": False,
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_PRODUCT_UPSERT_SQL, row)
def _seed_llm_billing_policies() -> None:
admin_user_id = "0019e0a44895b6d837d"
rows = [
{
"id": "0019fd6aa5084a85a96",
"scene_code": "generation_record_text_prompt_optimize",
"scene_name": "AI创作-提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:41:53.809940+08:00"),
"updated_at": _dt("2026-08-06T10:41:53.809940+08:00"),
},
{
"id": "0019fd6aa834169f61d",
"scene_code": "hot_opening_image_prompt_optimize",
"scene_name": "爆款开头复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:06.818840+08:00"),
"updated_at": _dt("2026-08-06T10:42:06.818840+08:00"),
},
{
"id": "0019fd6aac862f821ca",
"scene_code": "shot_image_prompt_optimize",
"scene_name": "拆镜复刻-图片提示词优化",
"pre_deduct_credits": Decimal("5.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:24.496938+08:00"),
"updated_at": _dt("2026-08-06T10:42:24.496938+08:00"),
},
{
"id": "0019fd6aaf2fcae0765",
"scene_code": "shot_video_prompt_optimize",
"scene_name": "拆镜复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:35.404414+08:00"),
"updated_at": _dt("2026-08-06T10:42:35.404414+08:00"),
},
{
"id": "0019fd6aaaf31d7c7c7",
"scene_code": "hot_opening_video_prompt_optimize",
"scene_name": "爆款开头复刻-视频提示词优化",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:42:18.003357+08:00"),
"updated_at": _dt("2026-08-06T10:42:39.440194+08:00"),
},
{
"id": "0019fd6abba149ef7e9",
"scene_code": "shot_original_video_analysis",
"scene_name": "拆镜复刻-原视频AI分析",
"pre_deduct_credits": Decimal("20.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:26.390337+08:00"),
"updated_at": _dt("2026-08-06T10:43:26.390337+08:00"),
},
{
"id": "0019fd6abdbc4182c17",
"scene_code": "shot_segment_video_analysis",
"scene_name": "拆镜复刻-片段视频AI分析",
"pre_deduct_credits": Decimal("10.00"),
"is_active": True,
"version": 1,
"created_by": admin_user_id,
"updated_by": admin_user_id,
"created_at": _dt("2026-08-06T10:43:35.017213+08:00"),
"updated_at": _dt("2026-08-06T10:43:35.017213+08:00"),
},
]
connection = op.get_bind()
for row in rows:
connection.execute(_POLICY_UPSERT_SQL, row)
def upgrade() -> None:
op.add_column(
"credit_products",
sa.Column(
"renewal_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
# 积分增值包不参与订阅续费,统一标记为关闭;已有订阅套餐默认保持开启。
op.execute(
"UPDATE credit_products "
"SET renewal_enabled = false "
"WHERE product_type = 'credit_addon'"
)
_seed_credit_products()
_seed_llm_billing_policies()
def downgrade() -> None:
# 产品和计费策略属于运营配置,降级仅撤销本次新增字段,不主动删除业务配置数据。
op.drop_column("credit_products", "renewal_enabled")
@@ -0,0 +1,75 @@
"""expand credit addon validity months to 1-36
Revision ID: b7e2c4d91a63
Revises: 1a4d1f095fa1
Create Date: 2026-08-11 13:15:00
"""
from alembic import op
revision = "b7e2c4d91a63"
down_revision = "1a4d1f095fa1"
branch_labels = None
depends_on = None
CONSTRAINT_NAME = "ck_credit_products_type_required_fields"
def _drop_type_constraint_if_exists() -> None:
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'credit_products'
AND c.conname = '{CONSTRAINT_NAME}'
) THEN
ALTER TABLE credit_products DROP CONSTRAINT {CONSTRAINT_NAME};
END IF;
END
$$;
"""
)
def _create_type_constraint(*, addon_condition: str) -> None:
op.create_check_constraint(
CONSTRAINT_NAME,
"credit_products",
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
f"AND {addon_condition} AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
)
def upgrade() -> None:
# 当前版本中增值包被旧约束固定为 validity_months = 1。
# 先移除旧约束,再开放为1-36个自然月;不修改任何现有商品数据。
_drop_type_constraint_if_exists()
_create_type_constraint(addon_condition="validity_months BETWEEN 1 AND 36")
def downgrade() -> None:
# 回退到旧版本时,旧约束只允许1个月。为保证 downgrade 可执行,
# 将现有增值包有效期恢复为旧版本唯一合法值1个月。
_drop_type_constraint_if_exists()
op.execute(
"UPDATE credit_products "
"SET validity_months = 1 "
"WHERE product_type = 'credit_addon' "
"AND validity_months IS DISTINCT FROM 1"
)
_create_type_constraint(addon_condition="validity_months = 1")
+4
View File
@@ -7,6 +7,8 @@ from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router
from app.api.admin.private_portrait import router as private_portrait_router
from app.api.admin.recharge_package import router as recharge_package_router
from app.api.admin.credit_management import router as credit_management_router
from app.api.admin.llm_billing import router as llm_billing_router
from app.api.admin.menu_config import router as menu_config_router
from app.api.admin.upload import router as admin_upload_router
from app.api.admin.contact import router as admin_contact_router
@@ -22,6 +24,8 @@ router.include_router(team_router)
router.include_router(home_material_router)
router.include_router(private_portrait_router)
router.include_router(recharge_package_router)
router.include_router(credit_management_router)
router.include_router(llm_billing_router)
router.include_router(menu_config_router)
router.include_router(admin_upload_router)
router.include_router(admin_contact_router)
@@ -0,0 +1,330 @@
from __future__ import annotations
import json
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.credit_balance import (
CREDIT_BALANCE_SOURCE_TYPE_LABELS,
CREDIT_BALANCE_STATUS_LABELS,
CREDIT_LEVEL_LABELS,
CreditBalanceSourceType,
)
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.schemas.credit_balance import AdminCreditDeductRequest, AdminCreditGrantRequest
from app.schemas.credit_product import CreditProductCreate, CreditProductRenewalUpdate, CreditProductUpdate
from app.services.credit.ledger_service import deduct_credits, grant_credits
from app.services.credit.product_service import product_to_dict
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.time_policy import add_natural_months, last_usable_at
from app.services.credit.utils import utc_now
from app.services.notification import create_notification
from app.services.operation_log import log_operation
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/credit-management", tags=["admin-credit-management"])
def _apply_product_payload(product: CreditProduct, payload: dict) -> None:
mapping = {"features": "features_json"}
for key, value in payload.items():
setattr(product, mapping.get(key, key), value)
if product.product_type == "credit_addon" and product.validity_months is None:
# 兼容旧管理端未提交有效期的请求,新建增值包仍默认1个月;
# 更新时未提交该字段则保留原值。
product.validity_months = 1
if product.product_type == "subscription":
product.price = product.regular_price or 0
product.grant_credits = None
product.validity_months = None
else:
product.renewal_enabled = False
product.tier_code = None
product.tier_rank = None
product.billing_cycle = None
product.monthly_grant_credits = None
product.first_purchase_price = None
product.regular_price = None
product.activity_price = None
product.activity_start_at = None
product.activity_end_at = None
def _validate_product_entity(product: CreditProduct) -> None:
if product.product_type == "subscription":
required = {
"套餐等级编码": product.tier_code,
"套餐等级顺序": product.tier_rank,
"订阅周期": product.billing_cycle,
"每月积分": product.monthly_grant_credits,
"首充价格": product.first_purchase_price,
"原价": product.regular_price,
}
missing = [label for label, value in required.items() if value is None]
if missing:
raise HTTPException(status_code=400, detail=f"订阅套餐缺少字段:{''.join(missing)}")
if product.activity_price is None:
if product.activity_start_at is not None or product.activity_end_at is not None:
raise HTTPException(status_code=400, detail="未配置活动价时不能单独配置活动周期")
elif product.activity_start_at is None or product.activity_end_at is None:
raise HTTPException(status_code=400, detail="配置活动价时必须同时配置活动开始和结束时间")
elif product.activity_end_at <= product.activity_start_at:
raise HTTPException(status_code=400, detail="活动结束时间必须晚于开始时间")
elif product.product_type == "credit_addon":
if product.grant_credits is None or product.price is None:
raise HTTPException(status_code=400, detail="积分增值包必须配置价格和积分数量")
if product.validity_months is None or not 1 <= int(product.validity_months) <= 36:
raise HTTPException(status_code=400, detail="积分增值包有效期必须为1-36个月")
else:
raise HTTPException(status_code=400, detail="不支持的积分商品类型")
@router.get("/products")
async def list_products(
product_type: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
stmt = select(CreditProduct)
if product_type:
stmt = stmt.where(CreditProduct.product_type == product_type)
result = await db.execute(stmt.order_by(CreditProduct.product_type, CreditProduct.sort_order, CreditProduct.id))
return [product_to_dict(item) for item in result.scalars().all()]
@router.post("/products")
async def create_product(
data: CreditProductCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
exists = await db.execute(select(CreditProduct.id).where(CreditProduct.product_code == data.product_code).limit(1))
if exists.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
product = CreditProduct(id=generate_id())
_apply_product_payload(product, data.model_dump())
_validate_product_entity(product)
db.add(product)
await db.flush()
snapshot = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"创建积分商品 {product.name}", "POST", "/admin/credit-management/products", detail=json.dumps(snapshot, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_CREATED", user_id=admin.id, detail=snapshot)
# 商品保存后前端会立即使用返回值刷新列表。这里显式提交,避免依赖
# yield 依赖退出阶段提交时出现紧随其后的 GET 读到旧状态。
await db.commit()
return snapshot
@router.put("/products/{product_id}")
async def update_product(
product_id: str,
data: CreditProductUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
before = product_to_dict(product)
payload = data.model_dump(exclude_unset=True)
new_code = payload.get("product_code")
if new_code and new_code != product.product_code:
duplicate = await db.execute(
select(CreditProduct.id).where(
CreditProduct.product_code == new_code, CreditProduct.id != product.id
).limit(1)
)
if duplicate.scalar_one_or_none():
raise HTTPException(status_code=409, detail="商品编码已存在")
_apply_product_payload(product, payload)
_validate_product_entity(product)
await db.flush()
after = product_to_dict(product)
await log_operation(db, admin.id, admin.username, f"更新积分商品 {product.name}", "PUT", f"/admin/credit-management/products/{product_id}", detail=json.dumps({"before": before, "after": after}, ensure_ascii=False, default=str))
log_operation_event(domain="credit_product", module="credit", event_type="CREDIT_PRODUCT_UPDATED", user_id=admin.id, detail={"product_id": product_id})
await db.commit()
refreshed = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1))
persisted = refreshed.scalar_one_or_none()
if persisted is None:
raise HTTPException(status_code=404, detail="商品不存在")
return product_to_dict(persisted)
@router.put("/products/{product_id}/renewal")
async def update_product_renewal(
product_id: str,
data: CreditProductRenewalUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.id == product_id)
.limit(1)
.with_for_update()
)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
if product.product_type != "subscription":
raise HTTPException(status_code=400, detail="积分增值包不支持续费开关")
before = bool(product.renewal_enabled)
product.renewal_enabled = bool(data.renewal_enabled)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"{'开启' if product.renewal_enabled else '关闭'}积分商品续费 {product.name}",
"PUT",
f"/admin/credit-management/products/{product_id}/renewal",
detail=json.dumps(
{"before": before, "after": bool(product.renewal_enabled)},
ensure_ascii=False,
),
)
await db.commit()
# 提交后重新查询,返回数据库真实持久化结果,避免前端误用事务内快照。
refreshed = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
)
persisted = refreshed.scalar_one_or_none()
if persisted is None:
raise HTTPException(status_code=404, detail="商品不存在")
if bool(persisted.renewal_enabled) != bool(data.renewal_enabled):
raise HTTPException(status_code=500, detail="续费状态保存后校验失败")
return product_to_dict(persisted)
@router.delete("/products/{product_id}")
async def disable_product(
product_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditProduct).where(CreditProduct.id == product_id).limit(1).with_for_update())
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="商品不存在")
product.is_active = False
await db.flush()
await log_operation(db, admin.id, admin.username, f"下架积分商品 {product.name}", "DELETE", f"/admin/credit-management/products/{product_id}")
await db.commit()
return {"ok": True}
@router.get("/users/{user_id}/summary")
async def get_user_credit_summary(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
return (await get_balance_summary(db, user_id)).to_dict()
@router.get("/users/{user_id}/balances")
async def list_user_credit_balances(
user_id: str,
status: str | None = Query(default=None),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == user_id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
result = await db.execute(stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()).offset((page - 1) * page_size).limit(page_size))
return [
{
"id": item.id,
"credit_level": item.credit_level,
"credit_level_label": CREDIT_LEVEL_LABELS.get(item.credit_level, item.credit_level),
"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,
"grant_amount": float(item.grant_amount),
"unspent_amount": float(item.unspent_amount),
"consumed_amount": float(item.consumed_amount),
"expired_amount": float(item.expired_amount),
"revoked_amount": float(item.revoked_amount),
"valid_from": item.valid_from,
"expires_at": item.expires_at,
"last_usable_at": last_usable_at(item.expires_at),
"status": (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()
]
@router.post("/users/{user_id}/grant")
async def admin_grant_credit(
user_id: str,
data: AdminCreditGrantRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
starts_at = data.valid_from or utc_now()
ends_at = starts_at + timedelta(days=data.validity_value) if data.validity_unit == "day" else add_natural_months(starts_at, data.validity_value)
result = await grant_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
valid_from=starts_at,
expires_at=ends_at,
credit_level=data.credit_level,
related_id=admin.id,
biz_key=f"admin-grant:{admin.id}:{generate_id()}",
)
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已增加{data.amount}积分。原因:{data.description}", "credit",
)
await log_operation(db, admin.id, admin.username, f"给用户 {user_id} 增加积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/grant")
return {"ok": True, "credits": result.balance_after}
@router.post("/users/{user_id}/deduct")
async def admin_deduct_credit(
user_id: str,
data: AdminCreditDeductRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
result = await deduct_credits(
db,
user_id=user_id,
amount=data.amount,
description=data.description,
related_id=admin.id,
biz_key=f"admin-deduct:{admin.id}:{generate_id()}",
)
except Exception as exc:
if exc.__class__.__name__ == "InsufficientCreditsError":
raise HTTPException(status_code=400, detail="用户有效积分不足") from exc
raise
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已扣除{data.amount}积分。原因:{data.description}", "credit",
)
await log_operation(db, admin.id, admin.username, f"扣除用户 {user_id} 积分 {data.amount}", "POST", f"/admin/credit-management/users/{user_id}/deduct")
return {"ok": True, "credits": result.balance_after}
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.enums.llm_billing import LLM_BILLING_SCENE_LABELS
from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.models.user import User
from app.schemas.llm_billing import LlmBillingPolicyCreate, LlmBillingPolicyUpdate
from app.services.llm_billing.query_service import list_executions_with_calls
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/admin/llm-billing", tags=["admin-llm-billing"])
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")
async def list_policies(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(LlmBillingPolicyModel).order_by(LlmBillingPolicyModel.scene_code))
return [_policy_out(item) for item in result.scalars().all()]
@router.post("/policies")
async def create_policy(
data: LlmBillingPolicyCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if data.scene_code not in LLM_BILLING_SCENE_LABELS:
raise HTTPException(status_code=400, detail="不支持的LLM业务场景")
existing = await db.execute(
select(LlmBillingPolicyModel.id)
.where(LlmBillingPolicyModel.scene_code == data.scene_code)
.limit(1)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="该LLM业务场景已存在")
policy = LlmBillingPolicyModel(
id=generate_id(),
scene_code=data.scene_code,
scene_name=LLM_BILLING_SCENE_LABELS[data.scene_code],
# 保留历史物理字段,不做无业务价值的数据库重命名迁移。
pre_deduct_credits=data.charge_credits,
is_active=data.is_active,
version=1,
created_by=admin.id,
updated_by=admin.id,
)
db.add(policy)
await db.flush()
policy_id = policy.id
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}")
async def update_policy(
policy_id: str,
data: LlmBillingPolicyUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.id == policy_id)
.limit(1)
.with_for_update()
)
policy = result.scalar_one_or_none()
if not policy:
raise HTTPException(status_code=404, detail="LLM积分场景不存在")
payload = data.model_dump(exclude_unset=True)
payload.pop("scene_name", None)
if "charge_credits" in payload:
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.version += 1
policy.updated_by = admin.id
await db.flush()
log_operation_event(
domain="llm_billing",
module="admin",
event_type="LLM_BILLING_POLICY_UPDATED",
user_id=admin.id,
detail={
"scene_code": policy.scene_code,
"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")
async def list_executions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
scene_code: str | None = Query(default=None),
status: str | None = Query(default=None),
user_id: str | None = Query(default=None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_executions_with_calls(
db,
page=page,
page_size=page_size,
scene_code=scene_code,
status=status,
user_id=user_id,
)
return {"items": items, "total": total}
+10 -134
View File
@@ -1,148 +1,24 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
from app.dependencies import get_admin_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.schemas.recharge_package import (
RechargePackageCreate,
RechargePackageUpdate,
RechargePackageOut,
)
from app.services.operation_log import log_operation
from app.utils.id_gen import generate_id
from app.services.credit.product_service import product_to_dict
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("", response_model=list[RechargePackageOut])
@router.get("")
async def admin_list_packages(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value)
.order_by(CreditProduct.sort_order, CreditProduct.id)
)
return [_to_out(p) for p in result.scalars().all()]
@router.post("", response_model=RechargePackageOut)
async def create_package(
data: RechargePackageCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
pkg = RechargePackage(id=generate_id(), **data.model_dump())
db.add(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建充值套餐 {pkg.name}",
"POST",
"/admin/recharge-packages",
detail=json.dumps(
{
"id": pkg.id,
"name": pkg.name,
"price": round(pkg.price, 2),
"credits": round(pkg.credits, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
},
ensure_ascii=False,
),
)
await db.commit()
return _to_out(pkg)
@router.put("/{pkg_id}", response_model=RechargePackageOut)
async def update_package(
pkg_id: str,
data: RechargePackageUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
before = _to_out(pkg)
for k, v in data.model_dump(exclude_unset=True).items():
setattr(pkg, k, v)
await db.flush()
after = _to_out(pkg)
await log_operation(
db,
admin.id,
admin.username,
f"更新充值套餐 {pkg.name}",
"PUT",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"before": before,
"after": after,
},
ensure_ascii=False,
),
)
await db.commit()
return _to_out(pkg)
@router.delete("/{pkg_id}")
async def delete_package(
pkg_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="套餐不存在")
pkg_name = pkg.name
await db.delete(pkg)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除充值套餐 {pkg_name}",
"DELETE",
f"/admin/recharge-packages/{pkg_id}",
detail=json.dumps(
{
"id": pkg_id,
"name": pkg_name,
},
ensure_ascii=False,
),
)
await db.commit()
return {"ok": True}
return [product_to_dict(item) for item in result.scalars().all()]
+2
View File
@@ -12,6 +12,7 @@ from app.api.v1.sms import router as sms_router
from app.api.v1.industries import router as industries_router
from app.api.v1.menu_configs import router as menu_configs_router
from app.api.v1.recharge_packages import router as recharge_packages_router
from app.api.v1.credit_products import router as credit_products_router
from app.api.v1.video_engines import router as video_engines_router
from app.api.v1.image_engines import router as image_engines_router
from app.api.v1.generation_ai import router as generation_ai_router
@@ -51,6 +52,7 @@ api_router.include_router(sms_router)
api_router.include_router(industries_router)
api_router.include_router(menu_configs_router)
api_router.include_router(recharge_packages_router)
api_router.include_router(credit_products_router)
api_router.include_router(video_engines_router)
api_router.include_router(image_engines_router)
api_router.include_router(generation_ai_router)
+38 -13
View File
@@ -48,10 +48,13 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
from app.services.llm_billing.config import validate_llm_system_config_value
from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
@@ -131,6 +134,9 @@ async def list_users(
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_map = await get_user_credit_map(db, user_ids)
for item in users:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
AdminUserOut.model_validate(user)
@@ -179,16 +185,29 @@ async def create_user(
hashed_password=hash_password(req.password),
email=req.email,
phone=req.phone,
credits=req.credits,
is_admin=req.is_admin if req.user_type == "admin" else False,
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_asset_limit=req.private_portrait_asset_limit,
)
user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
if req.credits > 0:
now = utc_now()
await add_credits(
db, user.id, req.credits, "管理员创建用户初始积分",
record_meta=build_admin_adjust_meta(),
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-create-user-credit:{user.id}",
)
else:
attach_credit_snapshot(user, 0)
await log_operation(
db,
admin.id,
@@ -258,7 +277,7 @@ async def get_user(
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.credits = round(user.credits, 2)
attach_credit_snapshot(user, await get_available_credits(db, user.id))
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
@@ -277,7 +296,17 @@ async def adjust_credits(
db: AsyncSession = Depends(get_db),
):
if req.amount > 0:
await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
now = utc_now()
await add_credits(
db, user_id, req.amount, f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
valid_from=now, expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await create_notification(
@@ -1527,6 +1556,8 @@ async def create_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
@@ -1555,6 +1586,8 @@ async def update_model_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
@@ -1630,10 +1663,6 @@ async def create_system_config(
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
try:
await validate_llm_system_config_value(db, key=req.key, value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config = SystemConfig(
id=generate_id(),
key=req.key,
@@ -1668,10 +1697,6 @@ async def update_system_config(
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
try:
await validate_llm_system_config_value(db, key=str(config.key), value=str(req.value))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
config.value = str(req.value)
await db.flush()
await log_operation(
+35 -26
View File
@@ -12,7 +12,6 @@ from app.dependencies import (
get_current_user_allow_password_pending,
get_db,
)
from app.models.credit_record import CreditRecord
from app.models.system_config import SystemConfig
from app.models.user import User
from app.schemas.auth import (
@@ -34,6 +33,10 @@ from app.services.auth import (
)
from app.services.sms import verify_sms_code
from app.services.resource_capacity_service import get_user_resource_capacity_usage
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.ledger_service import grant_credits
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.utils.id_gen import generate_id
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -95,50 +98,57 @@ async def _get_register_credits(db: AsyncSession) -> int:
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
if credits <= 0:
attach_credit_snapshot(user, 0)
return
record = CreditRecord(
id=generate_id(),
now = datetime.now(timezone.utc)
result = await grant_credits(
db,
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"注册赠送 {credits} 积分",
source_type=CreditBalanceSourceType.REGISTER_GIFT.value,
source_id=user.id,
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"register-gift:{user.id}",
request_time=now,
)
db.add(record)
attach_credit_snapshot(user, result.balance_after)
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
enabled_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
)
enabled = enabled_result.scalar_one_or_none() == "true"
if not enabled:
if enabled_result.scalar_one_or_none() != "true":
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return
credits_result = await db.execute(
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits").limit(1)
)
credits = int(credits_result.scalar_one_or_none() or "0")
if credits <= 0:
return
today = datetime.now(CST).date()
if user.last_login_at:
last_login_date = user.last_login_at.date()
if last_login_date >= today:
return
user.credits += credits
record = CreditRecord(
id=generate_id(),
now_cst = datetime.now(CST)
if credits > 0:
next_midnight_cst = datetime.combine(now_cst.date() + timedelta(days=1), datetime.min.time(), tzinfo=CST)
result = await grant_credits(
db,
user_id=user.id,
type="recharge",
amount=credits,
balance_after=user.credits,
description=f"每日登录赠送 {credits} 积分",
source_type=CreditBalanceSourceType.DAILY_LOGIN.value,
source_id=now_cst.date().isoformat(),
valid_from=now_cst,
expires_at=next_midnight_cst,
credit_level=CreditLevel.PROMOTIONAL.value,
related_id=user.id,
biz_key=f"daily-login:{user.id}:{now_cst.date().isoformat()}",
request_time=now_cst,
)
db.add(record)
attach_credit_snapshot(user, result.balance_after)
else:
attach_credit_snapshot(user, await get_available_credits(db, user.id))
@router.post(
@@ -228,7 +238,6 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
phone=req.phone,
hashed_password=hash_password(req.password),
password_set_at=datetime.now(CST),
credits=register_credits,
is_admin=False,
user_type="frontend",
)
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.credit_product import CreditProductCatalogOut
from app.services.credit.product_service import build_product_catalog
router = APIRouter(prefix="/credit-products", tags=["credit-products"])
@router.get("/catalog", response_model=CreditProductCatalogOut)
async def get_credit_product_catalog(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await build_product_catalog(db, user=current_user)
+75 -20
View File
@@ -1,20 +1,53 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.dependencies import get_current_user, get_db
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.models.credit_ratio import CreditRatio
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.schemas.credit import CreditBalanceOut, CreditRecordOut
from app.models.user import User
from app.models.video_engine import VideoEngine
from app.schemas.credit import CreditRecordOut
from app.schemas.credit_balance import CreditBalanceItemOut
from app.schemas.credit_ratio import CreditRatioOut
from app.services.credit.query_service import (
apply_balance_status_filter,
effective_balance_status,
get_balance_summary,
)
from app.services.credit.utils import utc_now
from app.services.credit.time_policy import last_usable_at
from app.services.credit_ratio_service import list_all_credit_ratios
from app.services.credits import get_records
router = APIRouter(prefix="/credits", tags=["credits"])
def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut:
return CreditBalanceItemOut(
id=item.id,
credit_level=item.credit_level,
source_type=item.source_type,
source_id=item.source_id,
product_id=item.product_id,
payment_order_id=item.payment_order_id,
subscription_id=item.subscription_id,
subscription_period_id=item.subscription_period_id,
grant_amount=float(item.grant_amount),
unspent_amount=float(item.unspent_amount),
consumed_amount=float(item.consumed_amount),
expired_amount=float(item.expired_amount),
revoked_amount=float(item.revoked_amount),
valid_from=item.valid_from,
expires_at=item.expires_at,
last_usable_at=last_usable_at(item.expires_at),
status=effective_balance_status(item, request_time=checked_at),
created_at=item.created_at,
)
@router.get("")
async def get_credits(
page: int = Query(1, ge=1),
@@ -23,18 +56,47 @@ async def get_credits(
db: AsyncSession = Depends(get_db),
):
records, total = await get_records(db, current_user.id, page, page_size)
summary = await get_balance_summary(db, current_user.id)
totals_result = await db.execute(
select(
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0),
func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0),
).where(CreditRecord.user_id == current_user.id)
)
total_granted, total_consumed, total_refunded, total_expired = totals_result.one()
return {
"credits": round(current_user.credits, 2),
**summary.to_dict(),
"records": [CreditRecordOut.model_validate(r) for r in records],
"total": total,
"total_granted": float(total_granted or 0),
"total_consumed": float(total_consumed or 0),
"total_refunded": float(total_refunded or 0),
"total_expired": float(total_expired or 0),
}
@router.get("/balances")
async def list_credit_balances(
status: str | None = Query(default=None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
checked_at = utc_now()
stmt = select(UserCreditBalance).where(UserCreditBalance.user_id == current_user.id)
stmt = apply_balance_status_filter(stmt, status, request_time=checked_at)
stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))
return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()]
@router.get(
"/credit-ratios",
response_model=list[CreditRatioOut],
summary="获取积分比例列表",
description="客户端获取当前系统配置的积分计费规则列表。普通登录用户可访问,只读返回 credit_ratios 表中的图片/视频积分比例配置。",
)
async def list_client_credit_ratios(
current_user: User = Depends(get_current_user),
@@ -60,28 +122,21 @@ async def get_credit_ratios(
return [CreditRatioOut.model_validate(r) for r in ratios]
return []
video_engines_result = await db.execute(
video_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None))
.order_by(VideoEngine.priority.desc())
)
video_engine_ids = video_engines_result.scalars().all()
image_engines_result = await db.execute(
image_result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None))
.order_by(ImageEngine.priority.desc())
)
image_engine_ids = image_engines_result.scalars().all()
grouped = {}
video_ratios = await get_ratios_for_engine_type("video", video_engine_ids)
video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all()))
image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all()))
if video_ratios:
grouped["video"] = video_ratios
image_ratios = await get_ratios_for_engine_type("image", image_engine_ids)
if image_ratios:
grouped["image"] = image_ratios
return grouped
@@ -17,7 +17,6 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.schemas.hot_opening_replicate import (
HotOpeningActionOut,
@@ -36,7 +35,6 @@ from app.schemas.hot_opening_replicate import (
)
from app.services.hot_opening_replicate_service import (
_get_project_for_user,
create_hot_opening_project,
delete_hot_opening_project,
generate_image_from_prompt,
generate_video_from_prompt,
@@ -66,8 +64,8 @@ from app.services.module_async_recovery_service import (
remove_active_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -181,11 +179,6 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if is_image
+64 -33
View File
@@ -9,17 +9,17 @@ logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.payment_order import PaymentOrder
from app.models.recharge_package import RechargePackage
from app.models.credit.product import CreditProduct
from app.services.credit.upgrade_service import release_upgrade_reservation
from app.services.credit.utils import utc_now
from app.schemas.payment import RechargeRequest, PaymentOrderOut
from app.services.payment import (
create_recharge_order,
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
PaymentCloseResult,
_resolve_order_for_cancellation,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -57,27 +57,23 @@ async def recharge(
raise HTTPException(status_code=400, detail="该支付方式未启用")
result = await db.execute(
select(RechargePackage).where(
RechargePackage.id == req.plan,
RechargePackage.is_active == True,
select(CreditProduct).where(
CreditProduct.id == req.plan,
CreditProduct.is_active.is_(True),
).limit(1)
)
.limit(1)
)
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=400, detail="无效的套餐")
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=400, detail="无效或已下架的积分商品")
try:
order = await create_recharge_order(
db,
current_user.id,
credits=pkg.credits,
price=pkg.price,
label=pkg.name,
bonus_credits=pkg.bonus_credits,
method=req.method,
product_id=product.id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return order
@@ -222,11 +218,30 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
total_amount = amount_info.get("total", 0) / 100 # 转换为元
if order_no:
await process_payment_success_by_order_no(db, order_no, transaction_id, total_amount)
try:
processed = await process_payment_success_by_order_no(
db, order_no, transaction_id, total_amount
)
if processed:
logger.info(
f"WeChat callback processed: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
else:
logger.warning(
f"WeChat payment success not fulfilled locally: order_no={order_no}, "
f"transaction_id={transaction_id}, amount={total_amount}"
)
except Exception as e:
# 微信已经通过验签、解密并明确通知支付成功。
# 本地履约异常属于项目内部故障:回滚本地事务,但仍向微信返回 SUCCESS,
# 后续由 pending 主动查单重试或日志人工对账处理。
await db.rollback()
logger.exception(
f"WeChat payment fulfillment error: order_no={order_no}, "
f"transaction_id={transaction_id}, error={e}"
)
return {"code": "SUCCESS", "message": "OK"}
# 处理退款回调
elif event_type == "REFUND.SUCCESS":
@@ -399,7 +414,7 @@ async def cancel_order(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.user_id == current_user.id,
).limit(1)
).with_for_update().limit(1)
)
order = result.scalar_one_or_none()
if not order:
@@ -407,21 +422,37 @@ async def cancel_order(
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
# If it's an Alipay or WeChat order, call close API first
# Resolve the authoritative gateway state before local cancellation.
# If WeChat explicitly reports ORDERPAID, the payment service queries only
# this order once and runs the normal atomic payment-success fulfillment.
db_configs = await _get_payment_configs(db)
if order.payment_method == "alipay":
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order_no}: {e}")
elif order.payment_method == "wechat":
try:
from app.services.payment import _close_wechat_order
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order_no}: {e}")
log_user_id = str(current_user.id)
payment_method = str(order.payment_method)
close_result = await _resolve_order_for_cancellation(db, order, db_configs)
if close_result == PaymentCloseResult.PAID:
logger.info(
f"ORDER_CANCEL_PAID_RECOVERED order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="订单已支付并完成支付处理,无法取消",
)
if close_result != PaymentCloseResult.CLOSED:
logger.warning(
f"ORDER_CANCEL_CLOSE_PENDING order_no={order_no} "
f"user={log_user_id} method={payment_method}"
)
raise HTTPException(
status_code=409,
detail="支付渠道暂未确认订单关闭,请稍后重试",
)
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
+25 -23
View File
@@ -1,39 +1,41 @@
"""Legacy endpoint retained for old clients; only active credit add-ons are returned."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.dependencies import get_current_user, get_db
from app.enums.credit_product import CreditProductType
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.recharge_package import RechargePackage
from app.services.credit.product_service import product_to_dict
router = APIRouter(tags=["recharge-packages"])
def _to_out(pkg: RechargePackage) -> dict:
return {
"id": pkg.id,
"name": pkg.name,
"credits": round(pkg.credits, 2),
"price": round(pkg.price, 2),
"bonus_credits": round(pkg.bonus_credits, 2),
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
"description": pkg.description,
"package_type": pkg.package_type,
"is_gift": pkg.is_gift,
"is_active": pkg.is_active,
"sort_order": pkg.sort_order,
}
@router.get("/recharge-packages")
async def list_active_packages(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Public: list active recharge packages."""
result = await db.execute(
select(RechargePackage)
.where(RechargePackage.is_active == True)
.order_by(RechargePackage.sort_order)
select(CreditProduct)
.where(
CreditProduct.product_type == CreditProductType.CREDIT_ADDON.value,
CreditProduct.is_active.is_(True),
)
return [_to_out(p) for p in result.scalars().all()]
.order_by(CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
items = []
for product in result.scalars().all():
payload = product_to_dict(product, user_price=product.price, price_type="regular", can_purchase=True)
payload.update(
{
"credits": payload["grant_credits"],
"bonus_credits": 0.0,
"total_credits": payload["grant_credits"],
"package_type": "credit_addon",
"is_gift": False,
}
)
items.append(payload)
return items
+1 -8
View File
@@ -20,7 +20,6 @@ from app.enums.credit_record import (
CreditRecordOwnerType,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -235,11 +234,6 @@ def _prompt_dispatch_billing_context(
source_step_id=step_id,
source_step_code=step_code,
related_id=step_id,
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化" if is_image else "拆镜复刻视频提词优化"
),
@@ -277,7 +271,6 @@ def _analysis_dispatch_billing_context(
source_step_id=owner_id,
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=owner_id,
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix=(
"拆镜复刻片段视频AI分析" if is_segment else "拆镜复刻原视频AI分析"
),
@@ -539,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_id = str(task_set.id)
if not created_new:
# 幂等重复请求不重复预扣和投递;已有 pending 任务由原投递或恢复任务继续处理。
# 幂等重复请求不重复消费积分和投递;已有 pending 任务由原投递或恢复任务继续处理。
await db.rollback()
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
analysis_attempt_no = max(1, int(task_set.analysis_attempt_no or 1))
+1 -16
View File
@@ -9,10 +9,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import StreamingResponse
from app.config import settings
from app.dependencies import get_current_user, get_db, get_optional_current_user
from app.models.team import Team
from app.models.team_invitation import TeamInvitation
from app.models.team_join_request import TeamJoinRequest
from app.models.user import User
from app.schemas.team_invitation import TeamInvitationCreate, TeamInvitationOut
@@ -23,17 +21,12 @@ from app.schemas.team_join_request import (
JoinTeamInfoOut,
)
from app.schemas.team_manager import (
ManagedTeamOut,
ManagerTransferRequest,
SetManagerRequest,
TeamMemberOut,
)
from app.services import team_invitation_service
from app.services.team_manager_service import (
get_managed_team,
get_team_members,
is_team_manager,
transfer_credits_to_member,
)
router = APIRouter(prefix="/team", tags=["team"])
@@ -92,15 +85,7 @@ async def transfer_credits(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await transfer_credits_to_member(
db,
current_user.id,
req.target_user_id,
req.amount,
req.direction or "increase",
req.description,
)
return {"message": "ok"}
raise HTTPException(status_code=409, detail="当前版本积分暂未开放团队转账功能")
# ── 邀请码管理 ────────────────────────────────────────
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
_UNRESOLVED_HOLDS_SQL = text(
"""
SELECT
h.id,
h.user_id,
h.biz_key,
h.amount,
h.created_at
FROM credit_records AS h
WHERE h.type = 'hold'
AND NOT EXISTS (
SELECT 1
FROM credit_records AS x
WHERE x.refund_for_biz_key = h.biz_key
OR x.biz_key IN (
REPLACE(h.biz_key, :hold_suffix, :hold_release_suffix),
REPLACE(h.biz_key, :hold_suffix, :charge_suffix)
)
)
ORDER BY h.created_at ASC
LIMIT 5000
"""
)
async def amain() -> int:
"""维护窗口检查脚本:列出旧 HOLD 未形成 RELEASE/CHARGE 的业务,禁止带病切换。"""
async with async_session() as db:
result = await db.execute(
_UNRESOLVED_HOLDS_SQL,
{
"hold_suffix": ":hold",
"hold_release_suffix": ":hold_release",
"charge_suffix": ":charge",
},
)
rows = result.mappings().all()
print(
json.dumps(
{
"checked_at": utc_now().isoformat(),
"unresolved_count": len(rows),
"items": [dict(row) for row in rows],
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from decimal import Decimal
from sqlalchemy import text
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.models.base import async_session
from app.services.credit.ledger_service import grant_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, utc_now
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="将 users.credits 一次性迁移到动态积分余额表。")
parser.add_argument("--batch-size", type=int, default=500)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--commit", action="store_true")
parser.add_argument("--after-id", default="")
return parser
async def amain(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
if args.dry_run and args.commit:
raise SystemExit("--dry-run 和 --commit 不能同时使用")
do_commit = bool(args.commit)
batch_size = max(1, min(int(args.batch_size or 500), 5000))
migration_time = utc_now()
cursor = str(args.after_id or "")
stats = {"positive_users": 0, "zero_users": 0, "negative_reset_users": 0, "migrated_credits": "0.00", "last_id": cursor}
total = Decimal("0.00")
while True:
async with async_session() as db:
rows = (await db.execute(
text("SELECT id, credits FROM users WHERE id > :cursor ORDER BY id ASC LIMIT :limit"),
{"cursor": cursor, "limit": batch_size},
)).mappings().all()
if not rows:
break
try:
for row in rows:
user_id = str(row["id"])
legacy = to_credit_decimal(row.get("credits") or 0)
cursor = user_id
stats["last_id"] = cursor
if legacy > 0:
stats["positive_users"] += 1
total += legacy
if do_commit:
await grant_credits(
db,
user_id=user_id,
amount=legacy,
description="历史用户积分迁移",
source_type=CreditBalanceSourceType.LEGACY_MIGRATION.value,
valid_from=migration_time,
expires_at=add_natural_months(migration_time, 12),
credit_level=CreditLevel.GENERAL.value,
source_id=user_id,
related_id=user_id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"legacy-user-credits:{user_id}",
metadata_json={"legacy_credits": str(legacy), "migration_time": migration_time.isoformat()},
request_time=migration_time,
)
elif legacy < 0:
stats["negative_reset_users"] += 1
else:
stats["zero_users"] += 1
if do_commit:
await db.commit()
else:
await db.rollback()
except Exception:
await db.rollback()
raise
stats["migrated_credits"] = str(total.quantize(Decimal("0.01")))
stats["migration_time"] = migration_time.isoformat()
stats["mode"] = "commit" if do_commit else "dry-run"
print(json.dumps(stats, ensure_ascii=False, indent=2))
return 0
def main() -> int:
return asyncio.run(amain(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
async with async_session() as db:
rows = (await db.execute(text("""
SELECT e.id, e.total_call_count, e.successful_call_count, e.failed_call_count,
e.total_input_tokens, e.total_output_tokens, e.total_tokens,
COUNT(a.id) AS actual_calls,
COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS actual_success,
COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0) AS actual_failed,
COALESCE(SUM(a.input_tokens), 0) AS actual_input,
COALESCE(SUM(a.output_tokens), 0) AS actual_output,
COALESCE(SUM(a.total_tokens), 0) AS actual_total
FROM llm_billing_executions e
LEFT JOIN llm_call_attempts a ON a.billing_execution_id = e.id
GROUP BY e.id
HAVING e.total_call_count <> COUNT(a.id)
OR e.successful_call_count <> COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0)
OR e.failed_call_count <> COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0)
OR e.total_tokens <> COALESCE(SUM(a.total_tokens), 0)
ORDER BY e.id
LIMIT 1000
"""))).mappings().all()
print(json.dumps({"checked_at": utc_now().isoformat(), "mismatch_count": len(rows), "items": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str))
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,39 @@
from __future__ import annotations
import asyncio
import json
from decimal import Decimal
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
checked_at = utc_now()
async with async_session() as db:
row = (await db.execute(text("""
SELECT
COALESCE(SUM(CASE WHEN credits > 0 THEN credits ELSE 0 END), 0) AS legacy_positive,
COALESCE((SELECT SUM(grant_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_grant,
COALESCE((SELECT SUM(unspent_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_unspent,
COALESCE((SELECT COUNT(*) FROM users WHERE credits < 0), 0) AS legacy_negative_users
FROM users
"""))).mappings().one()
legacy = Decimal(str(row["legacy_positive"] or 0)).quantize(Decimal("0.01"))
migrated = Decimal(str(row["migrated_grant"] or 0)).quantize(Decimal("0.01"))
output = {
"checked_at": checked_at.isoformat(),
"legacy_positive": str(legacy),
"migrated_grant": str(migrated),
"migrated_unspent": str(Decimal(str(row["migrated_unspent"] or 0)).quantize(Decimal("0.01"))),
"legacy_negative_users_reset_to_zero": int(row["legacy_negative_users"] or 0),
"matched": legacy == migrated,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0 if output["matched"] else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
+3
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.base import async_session
from app.models.user import User
from app.services.auth import decode_access_token, user_must_set_password
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
security = HTTPBearer(auto_error=False)
@@ -64,6 +65,7 @@ async def get_current_user_allow_password_pending(
detail="账号已在其他设备登录,请重新登录",
)
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
@@ -103,6 +105,7 @@ async def get_optional_current_user(
if user_must_set_password(user):
return None
attach_credit_snapshot(user, await get_available_credits(db, user.id))
return user
+3
View File
@@ -11,6 +11,7 @@ class CeleryQueue(str, Enum):
GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
GEN_SHOT_ANALYSIS = "gen_shot_analysis"
GEN_SHOT_SPLIT = "gen_shot_split"
GEN_CREDIT_MAINTENANCE = "gen_credit_maintenance"
GEN_API_UPSCALE = "gen_api_upscale"
DEFAULT = "default"
@@ -45,6 +46,8 @@ class CeleryTaskName(str, Enum):
PRIVATE_PORTRAIT_DELETE_GROUP = "private_portrait.delete_group_remote"
PRIVATE_PORTRAIT_DELETE_PROJECT = "private_portrait.delete_project_remote"
PRIVATE_PORTRAIT_RECOVER_REMOTE_DELETES = "private_portrait.recover_remote_deletes"
CREDIT_MAINTENANCE = "credit.maintenance_once"
PAYMENT_EXPIRE_PENDING = "payment.expire_pending_orders"
VP_V3_POLL_ASSET = "vp_v3.asset.poll_status"
VP_V3_SYNC_DUE_ASSETS = "vp_v3.sync_due_assets"
VP_V3_DELETE_ASSET = "vp_v3.asset.delete_remote"
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
from enum import StrEnum
class CreditLevel(StrEnum):
PROMOTIONAL = "promotional"
GENERAL = "general"
CREDIT_LEVEL_SORT = {
CreditLevel.PROMOTIONAL.value: 10,
CreditLevel.GENERAL.value: 20,
}
CREDIT_LEVEL_LABELS = {
CreditLevel.PROMOTIONAL.value: "活动积分",
CreditLevel.GENERAL.value: "普通积分",
}
class CreditBalanceSourceType(StrEnum):
REGISTER_GIFT = "register_gift"
DAILY_LOGIN = "daily_login"
SIGN_IN = "sign_in"
ACTIVITY = "activity"
ADMIN_GRANT = "admin_grant"
SUBSCRIPTION_GRANT = "subscription_grant"
CREDIT_ADDON = "credit_addon"
LEGACY_MIGRATION = "legacy_migration"
BUSINESS_REFUND = "business_refund"
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):
SCHEDULED = "scheduled"
ACTIVE = "active"
CONSUMED = "consumed"
EXPIRED = "expired"
REVOKED = "revoked"
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):
GRANT = "grant"
CONSUME = "consume"
REFUND_AVAILABLE = "refund_available"
REFUND_EXPIRED = "refund_expired"
EXPIRE = "expire"
REVOKE = "revoke"
UPGRADE_SOURCE_TRANSFER_OUT = "upgrade_source_transfer_out"
UPGRADE_SOURCE_TRANSFER_IN = "upgrade_source_transfer_in"
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: "升级积分转入",
}
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from enum import StrEnum
class CreditProductType(StrEnum):
SUBSCRIPTION = "subscription"
CREDIT_ADDON = "credit_addon"
class SubscriptionBillingCycle(StrEnum):
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
SUBSCRIPTION_GRANT_COUNT = {
SubscriptionBillingCycle.MONTHLY.value: 1,
SubscriptionBillingCycle.QUARTERLY.value: 3,
SubscriptionBillingCycle.YEARLY.value: 12,
}
class SubscriptionTierCode(StrEnum):
STARTER = "starter"
STANDARD = "standard"
ADVANCED = "advanced"
SUPER = "super"
class ProductPriceType(StrEnum):
FIRST_PURCHASE = "first_purchase"
REGULAR = "regular"
ACTIVITY = "activity"
UPGRADE = "upgrade"
+23 -4
View File
@@ -5,7 +5,9 @@ class CreditRecordType(str, Enum):
RECHARGE = "recharge"
CONSUME = "consume"
REFUND = "refund"
TEAM_INTERNAL = "team_internal" # 团队内部积分流转(管理人分配)
TEAM_INTERNAL = "team_internal" # 历史团队内部积分流转
EXPIRE = "expire"
REVOKE = "revoke"
class CreditRecordOwnerType(str, Enum):
@@ -56,6 +58,8 @@ class CreditRecordMediaType(str, Enum):
class CreditRecordAction(str, Enum):
CHARGE = "charge"
REFUND = "refund"
PRE_DEDUCT = "pre_deduct"
# 历史动作,仅用于展示旧流水,新代码不得继续创建。
HOLD = "hold"
HOLD_RELEASE = "hold_release"
@@ -111,15 +115,22 @@ class CreditRecordBillingScene(str, Enum):
ADMIN_ADJUST = "admin_adjust"
REFUND = "refund"
TEAM_INTERNAL_TRANSFER = "team_internal_transfer"
BUSINESS_FAILURE_REFUND_AVAILABLE = "business_failure_refund_available"
BUSINESS_FAILURE_REFUND_EXPIRED = "business_failure_refund_expired"
CREDIT_EXPIRE = "credit_expire"
CREDIT_REVOKE = "credit_revoke"
SUBSCRIPTION_GRANT = "subscription_grant"
CREDIT_ADDON_PURCHASE = "credit_addon_purchase"
UNKNOWN = "unknown"
CREDIT_RECORD_ACTION_LABELS = {
CreditRecordAction.CHARGE.value: "真实",
CreditRecordAction.CHARGE.value: "真实",
CreditRecordAction.PRE_DEDUCT.value: "历史固定预扣",
CreditRecordAction.REFUND.value: "真实退款",
CreditRecordAction.HOLD.value: "预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "预扣释放",
CreditRecordAction.HOLD.value: "历史预扣占用",
CreditRecordAction.HOLD_RELEASE.value: "历史预扣释放",
}
CREDIT_RECORD_TYPE_LABELS = {
@@ -127,6 +138,8 @@ CREDIT_RECORD_TYPE_LABELS = {
CreditRecordType.CONSUME.value: "消费",
CreditRecordType.REFUND.value: "回退",
CreditRecordType.TEAM_INTERNAL.value: "团队内部",
CreditRecordType.EXPIRE.value: "过期",
CreditRecordType.REVOKE.value: "撤销",
}
CREDIT_RECORD_SUBJECT_LABELS = {
@@ -209,5 +222,11 @@ CREDIT_RECORD_BILLING_SCENE_LABELS = {
CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整",
CreditRecordBillingScene.REFUND.value: "回退",
CreditRecordBillingScene.TEAM_INTERNAL_TRANSFER.value: "团队内部转账",
CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_AVAILABLE.value: "业务失败有效积分退款",
CreditRecordBillingScene.BUSINESS_FAILURE_REFUND_EXPIRED.value: "业务失败过期积分退款",
CreditRecordBillingScene.CREDIT_EXPIRE.value: "积分过期",
CreditRecordBillingScene.CREDIT_REVOKE.value: "积分撤销",
CreditRecordBillingScene.SUBSCRIPTION_GRANT.value: "订阅积分发放",
CreditRecordBillingScene.CREDIT_ADDON_PURCHASE.value: "积分增值包购买",
CreditRecordBillingScene.UNKNOWN.value: "历史未知",
}
@@ -0,0 +1,23 @@
from __future__ import annotations
from enum import StrEnum
class CreditSubscriptionStatus(StrEnum):
PENDING = "pending"
ACTIVE = "active"
EXPIRED = "expired"
UPGRADED = "upgraded"
CANCELLED = "cancelled"
REFUNDED = "refunded"
UPGRADE_RECONCILE_FAILED = "upgrade_reconcile_failed"
class CreditSubscriptionPeriodStatus(StrEnum):
SCHEDULED = "scheduled"
UPGRADE_RESERVED = "upgrade_reserved"
GRANTED = "granted"
CANCELLED_BY_UPGRADE = "cancelled_by_upgrade"
REVOKED_BY_UPGRADE = "revoked_by_upgrade"
CANCELLED = "cancelled"
EXPIRED = "expired"
+47 -33
View File
@@ -3,49 +3,55 @@ from __future__ import annotations
from enum import StrEnum
class LlmBillingConfigKey(StrEnum):
ENABLED = "llm_billing_enabled"
HOLD_DEFAULT = "llm_hold_credits_default"
HOLD_GENERATION_RECORD_PROMPT = "llm_hold_credits_generation_record_prompt"
HOLD_MODULE_IMAGE_PROMPT = "llm_hold_credits_module_image_prompt"
HOLD_MODULE_VIDEO_PROMPT = "llm_hold_credits_module_video_prompt"
HOLD_SHOT_VIDEO_ANALYSIS = "llm_hold_credits_shot_video_analysis"
LEGACY_OPTIMIZE_HOLD = "optimize_hold_credits"
class LlmBillingLedgerState(StrEnum):
BILLING_BYPASSED = "billing_bypassed"
MISSING = "missing"
ACTIVE = "active"
RELEASED = "released"
CHARGED = "charged"
SUCCEEDED = "succeeded"
REFUNDED = "refunded"
FINAL_FAILED = "final_failed"
INVALID = "invalid"
BILLING_BYPASSED = "invalid" # 历史兼容;新系统不允许绕过计费
RELEASED = "refunded" # 历史兼容别名
CHARGED = "active" # 当前 attempt 已完成真实消费,可继续执行
class LlmBillingExecutionStatus(StrEnum):
CHARGED = "charged"
PRE_DEDUCTED = "pre_deducted" # 历史兼容;新数据不得再创建
PROCESSING = "processing"
SUCCEEDED = "succeeded"
FINAL_FAILED = "final_failed"
REFUNDED = "refunded"
REFUND_FAILED = "refund_failed"
class LlmCallAttemptStatus(StrEnum):
STARTED = "started"
SUCCEEDED = "succeeded"
FAILED = "failed"
TIMEOUT = "timeout"
UNKNOWN = "unknown"
class LlmBillingEvent(StrEnum):
HOLD_START = "LLM_HOLD_START"
HOLD_SUCCESS = "LLM_HOLD_SUCCESS"
HOLD_BYPASSED = "LLM_HOLD_BYPASSED"
HOLD_INSUFFICIENT = "LLM_HOLD_INSUFFICIENT"
HOLD_CONFIG_INVALID = "LLM_HOLD_CONFIG_INVALID"
HOLD_MISSING = "LLM_HOLD_MISSING"
HOLD_RELEASE_START = "LLM_HOLD_RELEASE_START"
HOLD_RELEASE_SUCCESS = "LLM_HOLD_RELEASE_SUCCESS"
HOLD_RELEASE_SKIPPED = "LLM_HOLD_RELEASE_SKIPPED"
FAILURE_RELEASE_START = "LLM_FAILURE_RELEASE_START"
FAILURE_RELEASE_SUCCESS = "LLM_FAILURE_RELEASE_SUCCESS"
FAILURE_RELEASE_SKIPPED = "LLM_FAILURE_RELEASE_SKIPPED"
CHARGE_START = "LLM_CHARGE_START"
CHARGE_IDEMPOTENT_HIT = "LLM_CHARGE_IDEMPOTENT_HIT"
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
CHARGE_INSUFFICIENT = "LLM_CHARGE_INSUFFICIENT"
CHARGE_CONFIG_INVALID = "LLM_CHARGE_CONFIG_INVALID"
EXECUTION_VALIDATE_START = "LLM_EXECUTION_VALIDATE_START"
EXECUTION_VALIDATE_SUCCESS = "LLM_EXECUTION_VALIDATE_SUCCESS"
EXECUTION_BLOCKED = "LLM_EXECUTION_BLOCKED"
PROVIDER_START = "LLM_PROVIDER_START"
PROVIDER_SUCCESS = "LLM_PROVIDER_SUCCESS"
PROVIDER_FAILURE = "LLM_PROVIDER_FAILURE"
SETTLE_START = "LLM_SETTLE_START"
SETTLE_SUCCESS = "LLM_SETTLE_SUCCESS"
CHARGE_SUCCESS = "LLM_CHARGE_SUCCESS"
CHARGE_NEGATIVE_BALANCE = "LLM_CHARGE_NEGATIVE_BALANCE"
SETTLE_FAILED = "LLM_SETTLE_FAILED"
POSTPROCESS_FAILURE = "LLM_POSTPROCESS_FAILURE"
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
BUSINESS_SUCCESS = "LLM_BUSINESS_SUCCESS"
FINAL_FAILURE_START = "LLM_FINAL_FAILURE_START"
FINAL_FAILURE_REFUND_SUCCESS = "LLM_FINAL_FAILURE_REFUND_SUCCESS"
FINAL_FAILURE_REFUND_FAILED = "LLM_FINAL_FAILURE_REFUND_FAILED"
CELERY_DISPATCH_START = "LLM_CELERY_DISPATCH_START"
CELERY_DISPATCH_SUCCESS = "LLM_CELERY_DISPATCH_SUCCESS"
CELERY_DISPATCH_FAILURE = "LLM_CELERY_DISPATCH_FAILURE"
@@ -53,10 +59,18 @@ class LlmBillingEvent(StrEnum):
RETRY_PREVIOUS_ATTEMPT_VALIDATE_START = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_START"
RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS = "LLM_RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS"
RETRY_PREVIOUS_ATTEMPT_BLOCKED = "LLM_RETRY_PREVIOUS_ATTEMPT_BLOCKED"
USAGE_INVALID = "LLM_USAGE_INVALID"
TOKEN_USAGE_CREATED = "LLM_TOKEN_USAGE_CREATED"
TOKEN_USAGE_REUSED = "LLM_TOKEN_USAGE_REUSED"
class LlmBillingDomain(StrEnum):
LLM_BILLING = "llm_billing"
LLM_BILLING_SCENE_LABELS = {
"generation_record_text_prompt_optimize": "AI创作-提示词优化",
"hot_opening_image_prompt_optimize": "爆款开头复刻-图片提示词优化",
"hot_opening_video_prompt_optimize": "爆款开头复刻-视频提示词优化",
"shot_image_prompt_optimize": "拆镜复刻-图片提示词优化",
"shot_video_prompt_optimize": "拆镜复刻-视频提示词优化",
"shot_original_video_analysis": "拆镜复刻-原视频AI分析",
"shot_segment_video_analysis": "拆镜复刻-片段视频AI分析",
}
+7 -3
View File
@@ -18,9 +18,13 @@ class UploadResourceModuleEnum(StrEnum):
class UploadResourceTypeEnum(StrEnum):
"""上传资源类型。"""
IMAGE = "Image"
VIDEO = "Video"
AUDIO = "Audio"
IMAGE = "image"
VIDEO = "video"
AUDIO = "audio"
F_IMAGE = "Image"
F_VIDEO = "Video"
F_AUDIO = "Audio"
SHOT_SEGMENT = "shot_segment"
PDF = "pdf"
FILE = "file"
+4 -64
View File
@@ -32,29 +32,6 @@ async def lifespan(app: FastAPI):
await init_redis()
# await _seed_data()
# Background task: auto-expire pending payment orders and sync status
async def _order_expiry_loop():
from app.services.payment import expire_all_pending_orders, sync_pending_orders
from logging import getLogger
bg_logger = getLogger("payment")
while True:
try:
async with async_session() as db:
# 同步待支付订单状态(检查支付宝实际支付状态
sync_count = await sync_pending_orders(db)
if sync_count > 0:
bg_logger.info(f"Synced {sync_count} pending payment order(s)")
# 自动过期订单
n = await expire_all_pending_orders(db)
if n > 0:
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
except Exception as e:
bg_logger.error(f"Order expiry loop error: {e}")
await asyncio.sleep(60) # check every minute
expiry_task = asyncio.create_task(_order_expiry_loop())
# 启动token刷新定时任务(每5分钟检查一次,小于800秒有效期的token进行刷新)
from app.tasks.token_refresh_task import token_refresh_scheduler
token_refresh_task = asyncio.create_task(token_refresh_scheduler())
@@ -108,7 +85,6 @@ async def lifespan(app: FastAPI):
await consumption_queue_task
consumption_schedule_task.cancel()
pre_test_poll_task.cancel()
expiry_task.cancel()
token_refresh_task.cancel()
await close_database()
await close_redis()
@@ -138,7 +114,6 @@ async def _seed_data():
email="admin@videogen.ai",
phone="13800000000",
hashed_password=hash_password("123456"),
credits=10000,
is_admin=True,
user_type="admin",
)
@@ -154,7 +129,6 @@ async def _seed_data():
email="demo@videogen.ai",
phone="13888888888",
hashed_password=hash_password("123456"),
credits=2680,
is_admin=False,
user_type="frontend",
)
@@ -177,21 +151,12 @@ async def _seed_data():
("payment_alipay_enabled", "false", "支付宝启用"),
("payment_alipay_app_id", "", "支付宝AppID"),
("payment_alipay_private_key", "", "支付宝私钥"),
# Text credit config
("text_credits_per_1000_tokens", "1", "每1000 token消耗文本积分"),
# User credits config
("user_register_credits", "100", "用户注册赠送积分"),
("user_login_credits", "0", "用户每日登录赠送积分"),
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
# Operation manual
("operation_manual", "", "操作手册链接"),
("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
("llm_billing_enabled", "true", "是否启用 LLM 统一预扣与真实扣费结算"),
("llm_hold_credits_default", "5", "LLM 默认预扣积分数量"),
("llm_hold_credits_generation_record_prompt", "5", "AI创作提示词优化预扣积分数量"),
("llm_hold_credits_module_image_prompt", "5", "模块图片 AI 提词优化预扣积分数量"),
("llm_hold_credits_module_video_prompt", "10", "模块视频 AI 提词优化预扣积分数量"),
("llm_hold_credits_shot_video_analysis", "10", "拆镜视频分析预扣积分数量"),
]
for key, value, desc in configs:
existing = await db.execute(
@@ -439,7 +404,9 @@ async def _seed_data():
("/credit-records", "交易流水", "WalletOutlined", 2, None),
("/generation-ai", "创作记录", "BulbOutlined", 3, None),
("/generation-records", "项目记录", "VideoCameraOutlined", 3, None),
("/recharge-packages", "充值套餐", "GiftOutlined", 4, None),
("/credit-products", "积分产品", "GiftOutlined", 4, None),
("/llm-billing-policies", "LLM积分配置", "RobotOutlined", 5, "模型设置"),
("/llm-billing-executions", "LLM调用审计", "DatabaseOutlined", 6, "模型设置"),
("/notifications", "消息推送", "BellOutlined", 5, None),
("/payment-stats", "支付统计", "LineChartOutlined", 6, None),
("/video-engines", "视频引擎", "PlayCircleOutlined", 0, "模型设置"),
@@ -471,34 +438,7 @@ async def _seed_data():
logging.info("Default menu configs inserted successfully")
# Seed recharge packages
from app.models.recharge_package import RechargePackage
default_packages = [
("体验包", 500, 49, 0, "首次体验推荐", "normal", 0),
("进阶包", 2000, 168, 200, "最受欢迎", "normal", 1),
("专业包", 5000, 388, 500, "高性价比", "normal", 2),
("企业包", 20000, 1280, 2000, "团队首选", "normal", 3),
]
for name, credits, price, bonus, desc, ptype, order in default_packages:
existing = await db.execute(
select(RechargePackage).where(RechargePackage.name == name).limit(1)
)
if not existing.scalar_one_or_none():
db.add(
RechargePackage(
id=generate_id(),
name=name,
credits=credits,
price=price,
bonus_credits=bonus,
description=desc,
package_type=ptype,
is_gift=False,
is_active=True,
sort_order=order,
)
)
# 积分订阅套餐和增值包只允许管理后台人工录入;未配置时客户端不展示购买入口。
# Seed industry configs
from app.models.industry_config import IndustryConfig
+9 -1
View File
@@ -17,6 +17,11 @@ from app.models.video_engine import VideoEngine
from app.models.credit_ratio import CreditRatio
from app.models.menu_config import MenuConfig
from app.models.recharge_package import RechargePackage
from app.models.credit import (
CreditProduct, CreditRecordAllocation, UserCreditBalance,
UserCreditSubscription, UserCreditSubscriptionPeriod,
)
from app.models.llm_billing import LlmBillingPolicyModel, LlmBillingExecution, LlmCallAttempt
from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
@@ -47,7 +52,10 @@ __all__ = [
"User", "Team", "TeamInvitation", "TeamJoinRequest", "Project", "GenerationRecord", "CreditRecord",
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"MenuConfig", "RechargePackage", "CreditProduct", "CreditRecordAllocation", "UserCreditBalance",
"UserCreditSubscription", "UserCreditSubscriptionPeriod",
"LlmBillingPolicyModel", "LlmBillingExecution", "LlmCallAttempt",
"OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig",
@@ -0,0 +1,13 @@
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
__all__ = [
"CreditRecordAllocation",
"UserCreditBalance",
"CreditProduct",
"UserCreditSubscription",
"UserCreditSubscriptionPeriod",
]
@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class CreditRecordAllocation(Base, TimestampMixin):
__tablename__ = "credit_record_allocations"
__table_args__ = (
Index("ix_credit_record_allocations_record", "credit_record_id", "id"),
Index("ix_credit_record_allocations_balance", "credit_balance_id", "created_at"),
Index("ix_credit_record_allocations_user_time", "user_id", "created_at"),
Index("ix_credit_record_allocations_source_allocation", "source_allocation_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="CASCADE"), nullable=False
)
credit_balance_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="RESTRICT"), nullable=False
)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
source_allocation_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_record_allocations.id", ondelete="SET NULL"), nullable=True
)
allocation_action: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
credit_level_snapshot: Mapped[str] = mapped_column(String(32), nullable=False)
source_type_snapshot: Mapped[str] = mapped_column(String(48), nullable=False)
source_id_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
valid_from_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at_snapshot: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
unspent_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_before: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, JSON, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditBalanceStatus
from app.models.base import Base, TimestampMixin
class UserCreditBalance(Base, TimestampMixin):
__tablename__ = "user_credit_balances"
__table_args__ = (
CheckConstraint("grant_amount >= 0", name="ck_user_credit_balances_grant_nonnegative"),
CheckConstraint("unspent_amount >= 0", name="ck_user_credit_balances_unspent_nonnegative"),
CheckConstraint("consumed_amount >= 0", name="ck_user_credit_balances_consumed_nonnegative"),
CheckConstraint("expired_amount >= 0", name="ck_user_credit_balances_expired_nonnegative"),
CheckConstraint("revoked_amount >= 0", name="ck_user_credit_balances_revoked_nonnegative"),
CheckConstraint(
"grant_amount = unspent_amount + consumed_amount + expired_amount + revoked_amount",
name="ck_user_credit_balances_amount_reconciled",
),
CheckConstraint("expires_at > valid_from", name="ck_user_credit_balances_valid_window"),
Index(
"ix_user_credit_balances_spendable",
"user_id",
"credit_level_rank",
"expires_at",
"valid_from",
"id",
postgresql_where=text("unspent_amount > 0 AND revoked_at IS NULL"),
),
Index(
"ix_user_credit_balances_expire_due",
"expires_at",
"id",
postgresql_where=text(
"unspent_amount > 0 AND expired_processed_at IS NULL AND revoked_at IS NULL"
),
),
Index("ix_user_credit_balances_source", "source_type", "source_id"),
Index("ix_user_credit_balances_payment", "payment_order_id"),
Index("ix_user_credit_balances_subscription", "subscription_id", "subscription_period_id"),
Index("uq_user_credit_balances_user_biz_key", "user_id", "biz_key", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
credit_level: Mapped[str] = mapped_column(String(32), nullable=False)
credit_level_rank: Mapped[int] = mapped_column(nullable=False, default=20, server_default="20")
source_type: Mapped[str] = mapped_column(String(48), nullable=False, index=True)
source_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
subscription_period_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscription_periods.id", ondelete="SET NULL"), nullable=True
)
grant_record_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="SET NULL"), nullable=True, index=True
)
grant_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
unspent_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
consumed_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
expired_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
revoked_amount: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=CreditBalanceStatus.ACTIVE.value, server_default=CreditBalanceStatus.ACTIVE.value
)
expired_processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
biz_key: Mapped[str] = mapped_column(String(180), nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, CheckConstraint, DateTime, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_balance import CreditLevel
from app.enums.credit_product import CreditProductType
from app.models.base import Base, TimestampMixin
class CreditProduct(Base, TimestampMixin):
__tablename__ = "credit_products"
__table_args__ = (
Index("uq_credit_products_code", "product_code", unique=True),
Index("ix_credit_products_public", "product_type", "is_active", "sort_order"),
CheckConstraint("price >= 0", name="ck_credit_products_price_nonnegative"),
CheckConstraint("first_purchase_price IS NULL OR first_purchase_price >= 0", name="ck_credit_products_first_price_nonnegative"),
CheckConstraint("regular_price IS NULL OR regular_price >= 0", name="ck_credit_products_regular_price_nonnegative"),
CheckConstraint("activity_price IS NULL OR activity_price >= 0", name="ck_credit_products_activity_price_nonnegative"),
CheckConstraint("monthly_grant_credits IS NULL OR monthly_grant_credits > 0", name="ck_credit_products_monthly_grant_positive"),
CheckConstraint("grant_credits IS NULL OR grant_credits > 0", name="ck_credit_products_grant_positive"),
CheckConstraint(
"(product_type = 'subscription' AND tier_code IS NOT NULL AND tier_rank IS NOT NULL "
"AND billing_cycle IS NOT NULL AND monthly_grant_credits IS NOT NULL "
"AND first_purchase_price IS NOT NULL AND regular_price IS NOT NULL "
"AND grant_credits IS NULL AND validity_months IS NULL) "
"OR (product_type = 'credit_addon' AND grant_credits IS NOT NULL "
"AND validity_months BETWEEN 1 AND 36 AND tier_code IS NULL AND tier_rank IS NULL "
"AND billing_cycle IS NULL AND monthly_grant_credits IS NULL "
"AND first_purchase_price IS NULL AND regular_price IS NULL "
"AND activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL)",
name="ck_credit_products_type_required_fields",
),
CheckConstraint(
"(activity_price IS NULL AND activity_start_at IS NULL AND activity_end_at IS NULL) "
"OR (activity_price IS NOT NULL AND activity_start_at IS NOT NULL "
"AND activity_end_at IS NOT NULL AND activity_end_at > activity_start_at)",
name="ck_credit_products_activity_window",
),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
product_code: Mapped[str] = mapped_column(String(64), nullable=False)
product_type: Mapped[str] = mapped_column(String(24), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(96), nullable=False)
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
features_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 订阅套餐字段
tier_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
tier_rank: Mapped[int | None] = mapped_column(nullable=True)
billing_cycle: Mapped[str | None] = mapped_column(String(24), nullable=True)
monthly_grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
first_purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
regular_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_price: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
activity_start_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
activity_end_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
renewal_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="true"
)
# 积分增值包字段;有效期按自然月配置,范围1-36个月。
grant_credits: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
validity_months: Mapped[int | None] = mapped_column(nullable=True)
price: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
credit_level: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditLevel.GENERAL.value, server_default=CreditLevel.GENERAL.value
)
currency: Mapped[str] = mapped_column(String(8), nullable=False, default="CNY", server_default="CNY")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
sort_order: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
@property
def is_subscription(self) -> bool:
return self.product_type == CreditProductType.SUBSCRIPTION.value
@property
def is_credit_addon(self) -> bool:
return self.product_type == CreditProductType.CREDIT_ADDON.value
@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscription(Base, TimestampMixin):
__tablename__ = "user_credit_subscriptions"
__table_args__ = (
Index("ix_user_credit_subscriptions_current", "user_id", "status", "expires_at"),
Index("ix_user_credit_subscriptions_expire_due", "status", "expires_at", "id"),
Index("ix_user_credit_subscriptions_grant_due", "status", "next_grant_at", "id"),
Index("uq_user_credit_subscriptions_payment", "payment_order_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True
)
payment_order_id: Mapped[str] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionStatus.PENDING.value,
server_default=CreditSubscriptionStatus.PENDING.value,
)
purchase_scene: Mapped[str] = mapped_column(String(24), nullable=False)
tier_code: Mapped[str] = mapped_column(String(32), nullable=False)
tier_rank: Mapped[int] = mapped_column(nullable=False)
billing_cycle: Mapped[str] = mapped_column(String(24), nullable=False)
anchor_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
next_grant_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
monthly_grant_credits_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
grant_count: Mapped[int] = mapped_column(nullable=False)
granted_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
paid_amount_snapshot: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
product_snapshot_json: Mapped[dict] = mapped_column(JSON, nullable=False)
source_subscription_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="SET NULL"), nullable=True
)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.base import Base, TimestampMixin
class UserCreditSubscriptionPeriod(Base, TimestampMixin):
__tablename__ = "user_credit_subscription_periods"
__table_args__ = (
Index("uq_user_credit_subscription_periods_sequence", "subscription_id", "sequence", unique=True),
Index("ix_user_credit_subscription_periods_due", "status", "scheduled_at", "id"),
Index("ix_user_credit_subscription_periods_upgrade", "upgrade_order_id", "status"),
Index("uq_user_credit_subscription_periods_balance", "issued_balance_id", unique=True),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
subscription_id: Mapped[str] = mapped_column(
String(32), ForeignKey("user_credit_subscriptions.id", ondelete="CASCADE"), nullable=False
)
sequence: Mapped[int] = mapped_column(nullable=False)
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
valid_from: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
grant_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
allocated_paid_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
server_default=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
issued_balance_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("user_credit_balances.id", ondelete="SET NULL"), nullable=True
)
issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
upgrade_order_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
reserved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+25 -21
View File
@@ -1,4 +1,9 @@
from sqlalchemy import Float, ForeignKey, Index, Integer, String, text
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Numeric, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -7,14 +12,11 @@ from app.models.base import Base, TimestampMixin
class CreditRecord(Base, TimestampMixin):
__tablename__ = "credit_records"
__table_args__ = (
# 正式计费幂等键:同一用户同一个业务流水只能写入一次。
# PostgreSQL/MySQL/SQLite 对 nullable unique 的处理都允许多条 NULL,兼容历史数据。
Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True),
Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"),
Index(
"uq_credit_records_user_refund_target",
"user_id",
"refund_for_biz_key",
"uq_credit_records_user_refund_target_kind",
"user_id", "refund_for_biz_key", "refund_kind",
unique=True,
postgresql_where=text("type = 'refund' AND refund_for_biz_key IS NOT NULL"),
),
@@ -31,30 +33,30 @@ class CreditRecord(Base, TimestampMixin):
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
type: Mapped[str] = mapped_column(String(16), index=True)
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
description: Mapped[str] = mapped_column(String(256))
type: Mapped[str] = mapped_column(String(24), index=True)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
balance_delta: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
expired_amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
balance_after: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
description: Mapped[str] = mapped_column(String(512))
related_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
request_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
# 当前积分流水自己的业务幂等键。
# 例如:generation_record:{record_id}:attempt:1:media:charge
biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_for_biz_key: Mapped[str | None] = mapped_column(String(180), nullable=True, index=True)
refund_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
# 如果当前流水是退款,记录它退的是哪一次扣费。
# 例如:generation_record:{record_id}:attempt:1:media:charge
refund_for_biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
# 账务快照字段:保证业务步骤/资源软删后,流水仍可独立展示。
owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True)
charge_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
charge_action: Mapped[str | None] = mapped_column(String(16), nullable=True)
charge_action: Mapped[str | None] = mapped_column(String(24), nullable=True)
credit_subject: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
media_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
billing_scene: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
scene_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
credit_level_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True)
source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
source_project_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
@@ -65,7 +67,10 @@ class CreditRecord(Base, TimestampMixin):
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_success_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_failed_call_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
llm_billing_execution_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True)
engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
@@ -75,6 +80,5 @@ class CreditRecord(Base, TimestampMixin):
user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
# 交易流水发生时的团队归属冷备快照;用户后续改团队不影响历史流水展示与筛选。
team_id_snapshot: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
team_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
@@ -0,0 +1,5 @@
from app.models.llm_billing.call_attempt import LlmCallAttempt
from app.models.llm_billing.execution import LlmBillingExecution
from app.models.llm_billing.policy import LlmBillingPolicyModel
__all__ = ["LlmCallAttempt", "LlmBillingExecution", "LlmBillingPolicyModel"]
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmCallAttemptStatus
from app.models.base import Base, TimestampMixin
class LlmCallAttempt(Base, TimestampMixin):
__tablename__ = "llm_call_attempts"
__table_args__ = (
Index("uq_llm_call_attempts_sequence", "billing_execution_id", "call_sequence", unique=True),
Index("ix_llm_call_attempts_execution_time", "billing_execution_id", "created_at"),
Index("ix_llm_call_attempts_provider_request", "provider_request_id"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
billing_execution_id: Mapped[str] = mapped_column(
String(32), ForeignKey("llm_billing_executions.id", ondelete="CASCADE"), nullable=False
)
call_sequence: Mapped[int] = mapped_column(nullable=False)
retry_sequence: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_request_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
request_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
response_received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(24), nullable=False, default=LlmCallAttemptStatus.STARTED.value,
server_default=LlmCallAttemptStatus.STARTED.value,
)
input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
token_usage_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("token_usage.id", ondelete="SET NULL"), nullable=True
)
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_error_code: Mapped[str | None] = mapped_column(String(128), nullable=True)
error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
token_unavailable_reason: Mapped[str | None] = mapped_column(String(256), nullable=True)
postprocess_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
postprocess_error: Mapped[str | None] = mapped_column(String(1000), nullable=True)
@@ -0,0 +1,71 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.llm_billing import LlmBillingExecutionStatus
from app.models.base import Base, TimestampMixin
class LlmBillingExecution(Base, TimestampMixin):
__tablename__ = "llm_billing_executions"
__table_args__ = (
Index(
"uq_llm_billing_executions_business_attempt",
"user_id", "scene_code", "owner_type", "owner_id", "business_attempt_no",
unique=True,
),
Index("uq_llm_billing_executions_credit_record", "credit_record_id", unique=True),
Index("ix_llm_billing_executions_status_time", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
scene_name_snapshot: Mapped[str] = mapped_column(String(128), nullable=False)
owner_type: Mapped[str] = mapped_column(String(64), nullable=False)
owner_id: Mapped[str] = mapped_column(String(64), nullable=False)
business_attempt_no: Mapped[int] = mapped_column(nullable=False)
model_config_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("model_configs.id", ondelete="SET NULL"), nullable=True
)
model_name_snapshot: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
model_parameters_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
billing_policy_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("llm_billing_policies.id", ondelete="SET NULL"), nullable=True
)
billing_policy_version: Mapped[int | None] = mapped_column(nullable=True)
request_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credit_record_id: Mapped[str] = mapped_column(
String(32), ForeignKey("credit_records.id", ondelete="RESTRICT"), nullable=False
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
server_default=LlmBillingExecutionStatus.PRE_DEDUCTED.value,
)
total_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
successful_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
failed_call_count: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_input_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_output_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
total_tokens: Mapped[int] = mapped_column(nullable=False, default=0, server_default="0")
refund_available_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
refund_expired_credits: Mapped[Decimal] = mapped_column(
Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0"
)
final_error_message: Mapped[str | None] = mapped_column(String(1000), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -0,0 +1,22 @@
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import Boolean, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class LlmBillingPolicyModel(Base, TimestampMixin):
__tablename__ = "llm_billing_policies"
__table_args__ = (Index("uq_llm_billing_policies_scene", "scene_code", unique=True),)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
scene_code: Mapped[str] = mapped_column(String(64), nullable=False)
scene_name: Mapped[str] = mapped_column(String(128), nullable=False)
pre_deduct_credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
version: Mapped[int] = mapped_column(nullable=False, default=1, server_default="1")
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True)
+31 -15
View File
@@ -1,6 +1,9 @@
from datetime import datetime
from __future__ import annotations
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Index
from datetime import datetime
from decimal import Decimal
from sqlalchemy import DateTime, ForeignKey, Index, JSON, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
@@ -8,27 +11,40 @@ from app.models.base import Base, TimestampMixin
class PaymentOrder(Base, TimestampMixin):
__tablename__ = "payment_orders"
__table_args__ = (
Index("idx_payorder_user_status_created", "user_id", "status", "created_at"),
Index("idx_payorder_status_created", "status", "created_at"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
order_no: Mapped[str] = mapped_column(String(64), unique=True)
amount: Mapped[float] = mapped_column(Float)
credits: Mapped[float] = mapped_column(Float)
amount: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False)
credits: Mapped[Decimal] = mapped_column(Numeric(20, 2), nullable=False, default=Decimal("0.00"), server_default="0")
payment_method: Mapped[str] = mapped_column(String(16))
status: Mapped[str] = mapped_column(String(16), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status: Mapped[str] = mapped_column(String(32), default="pending")
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
refunded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refund_amount: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
__table_args__ = (
Index('idx_payorder_user_status_created', 'user_id', 'status', 'created_at'),
Index('idx_payorder_status_created', 'status', 'created_at'),
product_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("credit_products.id", ondelete="SET NULL"), nullable=True, index=True
)
product_type: Mapped[str | None] = mapped_column(String(24), nullable=True, index=True)
purchase_scene: Mapped[str | None] = mapped_column(String(24), nullable=True)
price_type: Mapped[str | None] = mapped_column(String(24), nullable=True)
product_code_snapshot: Mapped[str | None] = mapped_column(String(64), nullable=True)
product_name_snapshot: Mapped[str | None] = mapped_column(String(96), nullable=True)
product_snapshot_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
source_subscription_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
upgrade_period_ids_json: Mapped[list | None] = mapped_column(JSON, nullable=True)
target_price_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
deduction_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
payable_amount_snapshot: Mapped[Decimal | None] = mapped_column(Numeric(20, 2), nullable=True)
fulfillment_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
fulfilled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+6 -16
View File
@@ -1,19 +1,9 @@
from sqlalchemy import Boolean, Integer, String, Float
from sqlalchemy.orm import Mapped, mapped_column
"""兼容旧导入路径。
from app.models.base import Base, TimestampMixin
充值产品已统一迁移为 credit_products 新代码应直接导入 CreditProduct
"""
from app.models.credit.product import CreditProduct
RechargePackage = CreditProduct
class RechargePackage(Base, TimestampMixin):
__tablename__ = "recharge_packages"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64))
credits: Mapped[float] = mapped_column(Float)
price: Mapped[float] = mapped_column(Float)
bonus_credits: Mapped[float] = mapped_column(Float, default=0.0)
description: Mapped[str | None] = mapped_column(String(256), nullable=True)
package_type: Mapped[str] = mapped_column(String(32), default="normal")
is_gift: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
__all__ = ["CreditProduct", "RechargePackage"]
+14 -12
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON
from sqlalchemy import Boolean, DateTime, Integer, String, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.enums.user import FrontendUserKind
@@ -9,19 +9,17 @@ from app.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
__allow_unmapped__ = True
id: Mapped[str] = mapped_column(String(32), primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True)
phone: Mapped[str | None] = mapped_column(String(20), unique=True, nullable=True)
# 短信注册用户允许先没有密码,后续通过 /auth/set-password 设置。
hashed_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
avatar: Mapped[str | None] = mapped_column(String(512), nullable=True)
credits: Mapped[float] = mapped_column(Float, default=0.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
user_type: Mapped[str] = mapped_column(String(16), default="frontend", index=True)
# 仅前台用户有业务意义;默认外部用户。取消内部标记时也设置回 external。
frontend_user_kind: Mapped[str] = mapped_column(
String(16),
default=FrontendUserKind.EXTERNAL.value,
@@ -29,17 +27,13 @@ class User(Base, TimestampMixin):
index=True,
nullable=False,
)
# 当前归属团队,仅前台用户有业务意义;不影响 frontend_user_kind 内部/外部设置。
team_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
password_set_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
password_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
first_membership_paid_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
allowed_menus: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 私域人像素材总量限制。0 表示关闭模块;>0 表示启用并限制真人/虚拟、图片/视频素材总量。
private_portrait_asset_limit: Mapped[int] = mapped_column(
Integer, default=50, server_default="50", nullable=False
)
@@ -49,6 +43,14 @@ class User(Base, TimestampMixin):
Integer, default=0, server_default="0", nullable=False
)
@property
def credits(self) -> float:
return float(getattr(self, "_credits_snapshot", 0.0) or 0.0)
@credits.setter
def credits(self, value: float | int) -> None:
self._credits_snapshot = round(float(value or 0.0), 2)
@property
def must_set_password(self) -> bool:
return self.user_type == "frontend" and not self.hashed_password
+26
View File
@@ -188,6 +188,24 @@ class AdminCreditRecordSummaryOut(BaseModel):
output_tokens: int = 0
class AdminCreditRecordAllocationOut(BaseModel):
id: str
credit_balance_id: str
source_allocation_id: str | None = None
allocation_action: str
amount: float
credit_level: str
source_type: str
source_id: str | None = None
valid_from: str | None = None
expires_at: str | None = None
unspent_before: float = 0.0
unspent_after: float = 0.0
consumed_before: float = 0.0
consumed_after: float = 0.0
class AdminCreditRecordOut(BaseModel):
id: str
user_id: str
@@ -204,6 +222,8 @@ class AdminCreditRecordOut(BaseModel):
record_type: str
record_type_label: str | None = None
amount: float
balance_delta: float = 0.0
expired_amount: float = 0.0
balance_after: float
description: str | None = None
related_id: str | None = None
@@ -224,6 +244,8 @@ class AdminCreditRecordOut(BaseModel):
media_type_label: str | None = None
billing_scene: str | None = None
billing_scene_label: str | None = None
scene_name_snapshot: str | None = None
request_time: str | None = None
source_module: str | None = None
source_module_label: str | None = None
source_project_id: str | None = None
@@ -234,6 +256,10 @@ class AdminCreditRecordOut(BaseModel):
input_tokens: int = 0
output_tokens: int = 0
total_tokens: int = 0
llm_call_count: int = 0
llm_success_call_count: int = 0
llm_failed_call_count: int = 0
allocations: list[AdminCreditRecordAllocationOut] = Field(default_factory=list)
engine_type: str | None = None
engine_id: str | None = None
engine_name: str | None = None
+21 -3
View File
@@ -1,18 +1,36 @@
from pydantic import BaseModel
from __future__ import annotations
from app.schemas.common import NaiveDatetime
from datetime import datetime
from pydantic import BaseModel
class CreditRecordOut(BaseModel):
id: str
type: str
amount: float
balance_delta: float = 0
expired_amount: float = 0
balance_after: float
description: str
created_at: NaiveDatetime
billing_scene: str | None = None
scene_name_snapshot: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
llm_call_count: int | None = None
llm_success_call_count: int | None = None
llm_failed_call_count: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
class CreditBalanceOut(BaseModel):
credits: float
available_credits: float
next_expiring_credits: float = 0
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
records: list[CreditRecordOut]
total: int = 0
@@ -0,0 +1,48 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CreditBalanceItemOut(BaseModel):
id: str
credit_level: str
source_type: str
source_id: str | None = None
product_id: str | None = None
payment_order_id: str | None = None
subscription_id: str | None = None
subscription_period_id: str | None = None
grant_amount: float
unspent_amount: float
consumed_amount: float
expired_amount: float
revoked_amount: float
valid_from: datetime
expires_at: datetime
last_usable_at: datetime
status: str
created_at: datetime
class CreditBalanceSummaryOut(BaseModel):
credits: float
available_credits: float
next_expiring_credits: float
next_expires_at: datetime | None = None
next_last_usable_at: datetime | None = None
class AdminCreditGrantRequest(BaseModel):
amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
description: str = Field(..., min_length=1, max_length=256)
valid_from: datetime | None = None
validity_unit: str = Field(default="month", pattern="^(day|month)$")
validity_value: int = Field(default=1, ge=1, le=120)
credit_level: str = Field(default="general", pattern="^(promotional|general)$")
class AdminCreditDeductRequest(BaseModel):
amount: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
description: str = Field(..., min_length=1, max_length=256)
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class CreditProductBase(BaseModel):
product_code: str = Field(..., min_length=1, max_length=64)
product_type: Literal["subscription", "credit_addon"]
name: str = Field(..., min_length=1, max_length=96)
description: str | None = Field(default=None, max_length=512)
features: list[str] = Field(default_factory=list)
tier_code: str | None = Field(default=None, max_length=32)
tier_rank: int | None = Field(default=None, ge=1, le=999)
billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
activity_price: float | None = Field(default=None, ge=0, le=999999999.99)
activity_start_at: datetime | None = None
activity_end_at: datetime | None = None
renewal_enabled: bool = True
price: float = Field(default=0, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] = "general"
currency: str = Field(default="CNY", min_length=1, max_length=8)
is_active: bool = True
sort_order: int = Field(default=0, ge=-999999, le=999999)
@model_validator(mode="after")
def validate_product_fields(self):
if self.product_type == "subscription":
required = {
"tier_code": self.tier_code,
"tier_rank": self.tier_rank,
"billing_cycle": self.billing_cycle,
"monthly_grant_credits": self.monthly_grant_credits,
"first_purchase_price": self.first_purchase_price,
"regular_price": self.regular_price,
}
missing = [name for name, value in required.items() if value is None]
if missing:
raise ValueError(f"订阅套餐缺少字段: {', '.join(missing)}")
if self.activity_price is not None:
if not self.activity_start_at or not self.activity_end_at:
raise ValueError("配置活动价时必须同时配置活动开始和结束时间")
if self.activity_end_at <= self.activity_start_at:
raise ValueError("活动结束时间必须晚于开始时间")
else:
if self.grant_credits is None:
raise ValueError("积分增值包必须配置积分数量")
if self.price < 0:
raise ValueError("增值包价格不能小于0")
if self.validity_months is not None and not 1 <= self.validity_months <= 36:
raise ValueError("积分增值包有效期必须为1-36个月")
return self
class CreditProductCreate(CreditProductBase):
pass
class CreditProductUpdate(BaseModel):
product_code: str | None = Field(default=None, min_length=1, max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=96)
description: str | None = Field(default=None, max_length=512)
features: list[str] | None = None
tier_code: str | None = Field(default=None, max_length=32)
tier_rank: int | None = Field(default=None, ge=1, le=999)
billing_cycle: Literal["monthly", "quarterly", "yearly"] | None = None
monthly_grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
first_purchase_price: float | None = Field(default=None, ge=0, le=999999999.99)
regular_price: float | None = Field(default=None, ge=0, le=999999999.99)
activity_price: float | None = Field(default=None, ge=0, le=999999999.99)
activity_start_at: datetime | None = None
activity_end_at: datetime | None = None
renewal_enabled: bool | None = None
price: float | None = Field(default=None, ge=0, le=999999999.99)
grant_credits: float | None = Field(default=None, gt=0, le=999999999.99)
validity_months: int | None = Field(default=None, ge=1, le=36)
credit_level: Literal["promotional", "general"] | None = None
currency: str | None = Field(default=None, min_length=1, max_length=8)
is_active: bool | None = None
sort_order: int | None = Field(default=None, ge=-999999, le=999999)
class CreditProductRenewalUpdate(BaseModel):
renewal_enabled: bool
class CreditProductOut(BaseModel):
id: str
product_code: str
product_type: str
name: str
description: str | None = None
features: list[str] = Field(default_factory=list)
tier_code: str | None = None
tier_rank: int | None = None
billing_cycle: str | None = None
monthly_grant_credits: float = 0
grant_count: int = 1
first_purchase_price: float = 0
regular_price: float = 0
activity_price: float | None = None
activity_start_at: datetime | None = None
activity_end_at: datetime | None = None
renewal_enabled: bool = True
grant_credits: float = 0
validity_months: int | None = None
price: float
current_price: float
target_price: float | None = None
deduction_amount: float = 0
price_type: str | None = None
credit_level: str
currency: str
is_active: bool
sort_order: int
can_purchase: bool | None = None
can_upgrade: bool = False
unavailable_reason: str | None = None
class CreditProductCatalogOut(BaseModel):
subscription_products: list[CreditProductOut]
credit_addons: list[CreditProductOut]
first_purchase_available: bool
current_subscription: dict | None = None
@@ -0,0 +1,43 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
class CreditSubscriptionPeriodOut(BaseModel):
id: str
sequence: int
scheduled_at: datetime
valid_from: datetime
expires_at: datetime
grant_credits: float
allocated_paid_amount: float
status: str
issued_balance_id: str | None = None
issued_at: datetime | None = None
model_config = {"from_attributes": True}
class CreditSubscriptionOut(BaseModel):
id: str
product_id: str | None = None
payment_order_id: str
status: str
purchase_scene: str
tier_code: str
tier_rank: int
billing_cycle: str
anchor_at: datetime
start_at: datetime
expires_at: datetime
next_grant_at: datetime | None = None
monthly_grant_credits_snapshot: float
grant_count: int
granted_count: int
paid_amount_snapshot: float
source_subscription_id: str | None = None
periods: list[CreditSubscriptionPeriodOut] = []
model_config = {"from_attributes": True}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class LlmBillingPolicyCreate(BaseModel):
scene_code: str = Field(..., min_length=1, max_length=64)
scene_name: str | None = Field(default=None, min_length=1, max_length=128)
charge_credits: float = Field(..., gt=0, le=999999999.99, multiple_of=0.01)
is_active: bool = True
class LlmBillingPolicyUpdate(BaseModel):
scene_name: str | None = Field(default=None, min_length=1, max_length=128)
charge_credits: float | None = Field(default=None, gt=0, le=999999999.99, multiple_of=0.01)
is_active: bool | None = None
class LlmBillingPolicyOut(BaseModel):
id: str
scene_code: str
scene_name: str
charge_credits: float
is_active: bool
version: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
class LlmCallAttemptOut(BaseModel):
id: str
call_sequence: int
retry_sequence: int
model_config_id: str | None = None
model_name_snapshot: str | None = None
provider_snapshot: str | None = None
provider_request_id: str | None = None
request_started_at: datetime
response_received_at: datetime | None = None
duration_ms: int | None = None
status: str
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
http_status: int | None = None
provider_error_code: str | None = None
error_message: str | None = None
token_unavailable_reason: str | None = None
postprocess_status: str | None = None
postprocess_error: str | None = None
model_config = {"from_attributes": True}
class LlmBillingExecutionOut(BaseModel):
id: str
user_id: str
scene_code: str
scene_name_snapshot: str
owner_type: str
owner_id: str
business_attempt_no: int
model_config_id: str | None = None
model_name_snapshot: str | None = None
provider_snapshot: str | None = None
request_time: datetime
charge_credits: float
status: str
total_call_count: int
successful_call_count: int
failed_call_count: int
total_input_tokens: int
total_output_tokens: int
total_tokens: int
refund_available_credits: float
refund_expired_credits: float
final_error_message: str | None = None
completed_at: datetime | None = None
refunded_at: datetime | None = None
calls: list[LlmCallAttemptOut] = Field(default_factory=list)
+16 -4
View File
@@ -1,10 +1,13 @@
from pydantic import BaseModel
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class RechargeRequest(BaseModel):
plan: str # package id
method: str = "wechat" # "wechat" or "alipay"
plan: str = Field(..., description="积分商品ID;兼容旧字段名plan")
method: str = "wechat"
class PaymentOrderOut(BaseModel):
@@ -14,7 +17,16 @@ class PaymentOrderOut(BaseModel):
credits: float
payment_method: str
status: str
qr_url: str | None = None # Alipay QR code URL (transient, not persisted)
product_id: str | None = None
product_type: str | None = None
purchase_scene: str | None = None
price_type: str | None = None
product_name_snapshot: str | None = None
target_price_snapshot: float | None = None
deduction_amount_snapshot: float | None = None
payable_amount_snapshot: float | None = None
fulfillment_status: str | None = None
qr_url: str | None = None
created_at: datetime
paid_at: datetime | None = None
@@ -6,6 +6,11 @@ from typing import Any
from sqlalchemy import and_, case, distinct, func, or_, select
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 (
CREDIT_RECORD_ACTION_LABELS,
CREDIT_RECORD_BILLING_SCENE_LABELS,
@@ -20,6 +25,7 @@ from app.enums.credit_record import (
from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
@@ -128,6 +134,22 @@ def _build_filters(
if charge_kind:
filters.append(CreditRecord.charge_kind == charge_kind)
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)
if source_module:
filters.append(CreditRecord.source_module == source_module)
@@ -171,7 +193,31 @@ async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> di
return deleted_map
def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[tuple[str, str], tuple[bool, str | None]]) -> dict[str, Any]:
def _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(
record: CreditRecord,
user: User | None,
deleted_map: dict[tuple[str, str], tuple[bool, str | None]],
allocation_map: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]:
owner_deleted = False
owner_deleted_at = None
if record.owner_type and record.owner_id:
@@ -179,6 +225,7 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
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)
charge_action = _normalized_charge_action(record)
return {
"id": record.id,
"user_id": record.user_id,
@@ -195,8 +242,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"record_type": record.type,
"record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type),
"amount": _round2(record.amount),
"balance_delta": _round2(record.balance_delta),
"expired_amount": _round2(record.expired_amount),
"balance_after": _round2(record.balance_after),
"description": record.description,
"description": _normalized_description(record),
"related_id": record.related_id,
"biz_key": record.biz_key,
"refund_for_biz_key": record.refund_for_biz_key,
@@ -207,14 +256,16 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"attempt_no": record.attempt_no,
"charge_kind": record.charge_kind,
"charge_kind_label": _label(CREDIT_RECORD_CHARGE_KIND_LABELS, record.charge_kind),
"charge_action": record.charge_action,
"charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, record.charge_action),
"charge_action": charge_action,
"charge_action_label": _label(CREDIT_RECORD_ACTION_LABELS, charge_action),
"credit_subject": record.credit_subject,
"credit_subject_label": _label(CREDIT_RECORD_SUBJECT_LABELS, record.credit_subject),
"media_type": record.media_type,
"media_type_label": _label(CREDIT_RECORD_MEDIA_TYPE_LABELS, record.media_type),
"billing_scene": record.billing_scene,
"billing_scene_label": _label(CREDIT_RECORD_BILLING_SCENE_LABELS, record.billing_scene),
"scene_name_snapshot": record.scene_name_snapshot,
"request_time": _iso(record.request_time),
"source_module": record.source_module,
"source_module_label": _label(CREDIT_RECORD_SOURCE_MODULE_LABELS, record.source_module),
"source_project_id": record.source_project_id,
@@ -225,6 +276,10 @@ def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[t
"input_tokens": record.input_tokens or 0,
"output_tokens": record.output_tokens or 0,
"total_tokens": record.total_tokens or 0,
"llm_call_count": record.llm_call_count or 0,
"llm_success_call_count": record.llm_success_call_count or 0,
"llm_failed_call_count": record.llm_failed_call_count or 0,
"allocations": allocation_map.get(record.id, []),
"engine_type": record.engine_type,
"engine_id": record.engine_id,
"engine_name": record.engine_name,
@@ -293,63 +348,69 @@ async def list_admin_credit_records(
rows = result.all()
records = [row[0] for row in rows]
deleted_map = await _load_deleted_map(db, records)
items = [_record_to_item(record, user, deleted_map) for record, user in rows]
record_ids = [record.id for record in records]
allocation_map: dict[str, list[dict[str, Any]]] = {record_id: [] for record_id in record_ids}
if record_ids:
allocation_result = await db.execute(
select(CreditRecordAllocation)
.where(CreditRecordAllocation.credit_record_id.in_(record_ids))
.order_by(CreditRecordAllocation.credit_record_id.asc(), CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
)
for allocation in allocation_result.scalars().all():
allocation_map.setdefault(allocation.credit_record_id, []).append({
"id": allocation.id,
"credit_balance_id": allocation.credit_balance_id,
"source_allocation_id": allocation.source_allocation_id,
"allocation_action": allocation.allocation_action,
"allocation_action_label": _label(CREDIT_ALLOCATION_ACTION_LABELS, allocation.allocation_action),
"amount": _round2(allocation.amount),
"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_label": _label(CREDIT_BALANCE_SOURCE_TYPE_LABELS, allocation.source_type_snapshot),
"source_id": allocation.source_id_snapshot,
"valid_from": _iso(allocation.valid_from_snapshot),
"expires_at": _iso(allocation.expires_at_snapshot),
"unspent_before": _round2(allocation.unspent_before),
"unspent_after": _round2(allocation.unspent_after),
"consumed_before": _round2(allocation.consumed_before),
"consumed_after": _round2(allocation.consumed_after),
})
items = [_record_to_item(record, user, deleted_map, allocation_map) for record, user in rows]
# 说明:
# - consume 类型:amount 是负数(扣减积分),统计用 abs() 保证为正值
# team_internal(团队内部积分流转/管理员分配)不参与消费/扣费统计——它不是真实消费
# - refund 类型:amount 是正数(退回积分),为兼容旧数据/边缘场景也用 abs() 保证统计值恒正
# 子分类:真实退款 refund(action='refund'/NULL) + 预扣释放 hold_release(action='hold_release')
# - recharge 类型:amount 是正数(充值增加),金额直接求和,不需要 abs
#
# 口径更新(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」口径不变:
# 预扣是按任务预估的冻结,不是按图/视频实际产出,会让子分类统计失真。
# 当前主口径只统计真实消费/真实退款;hold/hold_release 仅保留历史兼容字段,不再并入总消费/总退款。
# 历史 LLM Billing 的 consume + pre_deduct + llm_billing_execution_id 本质已经真实减余额,
# 查询统计时按 charge 兼容,但不批量改写历史数据库。
_legacy_llm_charge_action = and_(
CreditRecord.charge_action == "pre_deduct",
CreditRecord.llm_billing_execution_id.is_not(None),
)
_real_charge_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
_charge_or_hold_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
CreditRecord.charge_action == "hold",
_legacy_llm_charge_action,
)
_real_refund_action = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "refund",
)
# 仅统计 type=consume 的消费类(排除 team_internal 团队内部转账)
_consume_type = CreditRecord.type == "consume"
# 预扣释放 / 真实退款 filter(都是 type=refund,账本 L256 强校验 hold_release.type=refund
_refund_type = CreditRecord.type == "refund"
_hold_release_filter = and_(
CreditRecord.type == "refund",
_refund_type,
CreditRecord.charge_action == "hold_release",
)
_refund_type = CreditRecord.type == "refund"
summary_query = select(
# 0: 充值
func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0),
# 1: 总消费 = total_charge + total_hold(真实扣费 + 预扣占用)
func.coalesce(func.sum(case((and_(_consume_type, _charge_or_hold_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 2: 总回退 = 真实退款 + 预扣释放(type=refund 全部流水)
func.coalesce(func.sum(case((_refund_type, func.abs(CreditRecord.amount)), else_=0)), 0),
# 1: 总真实消费
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 2: 总真实退款
func.coalesce(func.sum(case((and_(_refund_type, _real_refund_action), func.abs(CreditRecord.amount)), else_=0)), 0),
# 3: 交易笔数
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(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))),
@@ -358,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.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),
# 12-14: Token
func.coalesce(func.sum(CreditRecord.total_tokens), 0),
func.coalesce(func.sum(CreditRecord.input_tokens), 0),
func.coalesce(func.sum(CreditRecord.output_tokens), 0),
# 15: 真实扣费 total_charge(独立列:type=consume AND charge_action in (NULL, 'charge')
# 12-14: Token 仅统计真实 LLM/媒体消费流水,避免退款/历史释放重复累计。
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.total_tokens), else_=0)), 0),
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.input_tokens), else_=0)), 0),
func.coalesce(func.sum(case((and_(_consume_type, _real_charge_action), CreditRecord.output_tokens), else_=0)), 0),
# 15: total_charge 与 total_consume 同口径,保留字段供旧前端兼容。
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),
# 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),
# 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),
).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True)
if where_clause is not None:
@@ -0,0 +1,35 @@
from app.services.credit.expiration_service import (
archive_expired_balances_batch,
archive_expired_user_balances,
)
from app.services.credit.ledger_service import (
CreditMutationResult,
CreditRefundResult,
deduct_credits,
grant_credits,
refund_consumption,
revoke_balances,
)
from app.services.credit.query_service import (
CreditBalanceSummary,
attach_credit_snapshot,
get_available_credits,
get_balance_summary,
get_user_credit_map,
)
__all__ = [
"CreditMutationResult",
"CreditRefundResult",
"CreditBalanceSummary",
"deduct_credits",
"grant_credits",
"refund_consumption",
"revoke_balances",
"archive_expired_balances_batch",
"archive_expired_user_balances",
"attach_credit_snapshot",
"get_available_credits",
"get_balance_summary",
"get_user_credit_map",
]
@@ -0,0 +1,138 @@
from __future__ import annotations
from collections import Counter
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditAllocationAction, CreditBalanceStatus
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import get_available_credits
from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id
async def archive_expired_user_balances(
db: AsyncSession,
*,
user_id: str,
request_time: datetime | None = None,
limit: int = 500,
) -> int:
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.expires_at <= checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.expired_processed_at.is_(None),
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.limit(max(1, limit))
.with_for_update()
)
balances = list(result.scalars().all())
if not balances:
return 0
current_available = await get_available_credits(db, user_id, request_time=checked_at)
for balance in balances:
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
continue
before_consumed = to_credit_decimal(balance.consumed_amount)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.EXPIRE.value,
amount=-amount,
balance_delta=Decimal("0.00"),
expired_amount=amount,
balance_after=current_available,
description=f"积分到期:{float(amount):.2f}",
related_id=balance.id,
request_time=checked_at,
biz_key=f"credit-balance:{balance.id}:expire",
billing_scene=CreditRecordBillingScene.CREDIT_EXPIRE.value,
credit_level_snapshot=balance.credit_level,
)
db.add(record)
await db.flush()
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.EXPIRE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=amount,
unspent_after=Decimal("0.00"),
consumed_before=before_consumed,
consumed_after=before_consumed,
)
)
balance.unspent_amount = Decimal("0.00")
balance.expired_amount = to_credit_decimal(balance.expired_amount) + amount
balance.expired_processed_at = checked_at
balance.status = CreditBalanceStatus.EXPIRED.value
await db.flush()
return len(balances)
async def list_expired_balance_user_limits(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 500,
) -> list[tuple[str, int]]:
result = await db.execute(
select(UserCreditBalance.id, UserCreditBalance.user_id)
.where(
UserCreditBalance.expires_at <= request_time,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.expired_processed_at.is_(None),
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc())
.limit(max(1, batch_size))
)
selected = [(str(row.id), str(row.user_id)) for row in result.all()]
per_user_limit = Counter(user_id for _, user_id in selected)
return sorted(per_user_limit.items(), key=lambda item: item[0])
async def archive_expired_balances_batch(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 500,
) -> int:
checked_at = request_time or utc_now()
user_limits = await list_expired_balance_user_limits(
db, request_time=checked_at, batch_size=batch_size
)
processed = 0
for user_id, user_limit in user_limits:
processed += await archive_expired_user_balances(
db,
user_id=user_id,
request_time=checked_at,
limit=user_limit,
)
return processed
@@ -0,0 +1,791 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Iterable
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CREDIT_LEVEL_SORT,
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
CreditLevel,
)
from app.enums.credit_record import (
CreditRecordAction,
CreditRecordBillingScene,
CreditRecordType,
)
from app.enums.common import BillingBlockEventEnum
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit_record import CreditRecord
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
from app.services.operation_log_service import log_operation_event
from app.utils.exceptions import InsufficientCreditsError
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
refund_available: float = 0.0
refund_expired: float = 0.0
@dataclass(slots=True, frozen=True)
class _RefundAllocationSource:
original_allocation: CreditRecordAllocation
credit_balance_id: str
amount: Decimal
@dataclass(slots=True, frozen=True)
class CreditRefundResult:
records: tuple[CreditRecord, ...]
created: bool
total_amount: Decimal
available_amount: Decimal
expired_amount: Decimal
balance_before: Decimal
balance_after: Decimal
async def _load_user(db: AsyncSession, user_id: str) -> User:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if user is None:
raise ValueError("User not found")
return user
async def _record_meta_kwargs(
db: AsyncSession,
user: User,
record_meta: CreditRecordMeta | dict | None,
) -> dict:
if isinstance(record_meta, CreditRecordMeta):
populated = await with_user_snapshot(db, record_meta, user.id, user=user)
return populated.to_record_kwargs()
if isinstance(record_meta, dict):
return {key: value for key, value in record_meta.items() if value is not None}
return {}
async def _find_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
async def grant_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
source_type: str,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
related_id: str | None = None,
record_type: str = CreditRecordType.RECHARGE.value,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
metadata_json: dict | None = None,
request_time: datetime | None = None,
) -> CreditMutationResult:
grant_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
starts_at = valid_from or checked_at
ends_at = expires_at or add_natural_months(starts_at, 1)
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if grant_amount <= 0:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if ends_at <= starts_at:
raise ValueError("积分过期时间必须晚于生效时间")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before + grant_amount if starts_at <= checked_at < ends_at else before
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=grant_amount,
balance_delta=(grant_amount if starts_at <= checked_at < ends_at else Decimal("0.00")),
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
credit_level_snapshot=credit_level,
**meta_kwargs,
)
db.add(record)
await db.flush()
balance = UserCreditBalance(
id=generate_id(),
user_id=user_id,
credit_level=credit_level,
credit_level_rank=CREDIT_LEVEL_SORT.get(credit_level, CREDIT_LEVEL_SORT[CreditLevel.GENERAL.value]),
source_type=source_type,
source_id=source_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
grant_record_id=record.id,
grant_amount=grant_amount,
unspent_amount=grant_amount,
consumed_amount=Decimal("0.00"),
expired_amount=Decimal("0.00"),
revoked_amount=Decimal("0.00"),
valid_from=starts_at,
expires_at=ends_at,
status=CreditBalanceStatus.ACTIVE.value,
biz_key=f"{biz_key or record.id}:balance",
metadata_json=metadata_json,
)
db.add(balance)
await db.flush()
allocation = CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.GRANT.value,
amount=grant_amount,
request_time=checked_at,
credit_level_snapshot=credit_level,
source_type_snapshot=source_type,
source_id_snapshot=source_id,
valid_from_snapshot=starts_at,
expires_at_snapshot=ends_at,
unspent_before=Decimal("0.00"),
unspent_after=grant_amount,
consumed_before=Decimal("0.00"),
consumed_after=Decimal("0.00"),
)
db.add(allocation)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_GRANTED",
event_status="success",
source="app.services.credit.ledger_service.grant_credits",
user_id=user_id,
task_id=related_id,
message="动态积分发放完成",
detail={
"record_id": record.id,
"balance_id": balance.id,
"amount": to_float(grant_amount),
"source_type": source_type,
"credit_level": credit_level,
"valid_from": starts_at.isoformat(),
"expires_at": ends_at.isoformat(),
"biz_key": biz_key,
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(grant_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def deduct_credits(
db: AsyncSession,
*,
user_id: str,
amount: Decimal | float | int,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
record_type: str = CreditRecordType.CONSUME.value,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> CreditMutationResult:
consume_amount = to_credit_decimal(amount)
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing is not None:
attach_credit_snapshot(user, before)
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(to_float(existing.amount)),
balance_before=to_float(before),
balance_after=to_float(before),
)
if consume_amount <= 0 and not create_zero_record:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, None, False, 0.0, to_float(before), to_float(before))
if consume_amount > before:
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type=BillingBlockEventEnum.INSUFFICIENT_CREDITS.value,
event_status="failed",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="有效积分不足,已在创建业务任务前同步拦截",
detail={
"required_credits": to_float(consume_amount),
"available_credits": to_float(before),
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
"description": description,
},
)
raise InsufficientCreditsError()
result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(
UserCreditBalance.credit_level_rank.asc(),
UserCreditBalance.expires_at.asc(),
UserCreditBalance.valid_from.asc(),
UserCreditBalance.id.asc(),
)
.with_for_update()
)
balances = list(result.scalars().all())
remaining = consume_amount
allocations_data: list[tuple[UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for balance in balances:
if remaining <= 0:
break
available = to_credit_decimal(balance.unspent_amount)
if available <= 0:
continue
allocated = min(available, remaining)
before_unspent = available
before_consumed = to_credit_decimal(balance.consumed_amount)
after_unspent = before_unspent - allocated
after_consumed = before_consumed + allocated
balance.unspent_amount = after_unspent
balance.consumed_amount = after_consumed
if after_unspent == 0:
balance.status = CreditBalanceStatus.CONSUMED.value
allocations_data.append(
(balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed)
)
remaining -= allocated
if remaining > 0:
# 理论上用户级锁和前置汇总后不应发生;保留硬失败以避免部分扣除。
raise RuntimeError("积分分摊不足,事务将回滚")
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
after = before - consume_amount
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=-consume_amount,
balance_delta=-consume_amount,
expired_amount=Decimal("0.00"),
balance_after=after,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.CONSUME.value,
amount=allocated,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for balance, allocated, before_unspent, after_unspent, before_consumed, after_consumed in allocations_data
]
)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_DEDUCTED",
event_status="success",
source="app.services.credit.ledger_service.deduct_credits",
user_id=user_id,
task_id=related_id,
message="动态积分同步扣除完成",
detail={
"record_id": record.id,
"amount": to_float(consume_amount),
"balance_before": to_float(before),
"balance_after": to_float(after),
"allocation_count": len(allocations_data),
"allocation_balance_ids": [item[0].id for item in allocations_data[:20]],
"biz_key": biz_key,
"request_time": checked_at.isoformat(),
},
)
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=to_float(consume_amount),
balance_before=to_float(before),
balance_after=to_float(after),
)
async def _load_original_consume_allocations(
db: AsyncSession,
*,
original_record_id: str,
) -> list[_RefundAllocationSource]:
result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_record_id == original_record_id,
CreditRecordAllocation.allocation_action == CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.id.desc())
)
originals = list(result.scalars().all())
if not originals:
return []
original_ids = [item.id for item in originals]
transfer_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action
== CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
)
.order_by(CreditRecordAllocation.id.asc())
)
transfers_by_source: dict[str, list[CreditRecordAllocation]] = {}
for item in transfer_result.scalars().all():
if item.source_allocation_id:
transfers_by_source.setdefault(item.source_allocation_id, []).append(item)
sources: list[_RefundAllocationSource] = []
for original in originals:
transferred = Decimal("0.00")
for transfer in transfers_by_source.get(original.id, []):
amount = to_credit_decimal(transfer.amount)
transferred += amount
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=transfer.credit_balance_id,
amount=amount,
)
)
remaining = to_credit_decimal(original.amount) - transferred
if remaining < 0:
raise RuntimeError("升级积分来源迁移金额超过原消费分摊")
if remaining > 0:
sources.append(
_RefundAllocationSource(
original_allocation=original,
credit_balance_id=original.credit_balance_id,
amount=remaining,
)
)
return sources
async def refund_consumption(
db: AsyncSession,
*,
user_id: str,
refund_for_biz_key: str,
description: str,
related_id: str | None = None,
biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
refund_time: datetime | None = None,
) -> CreditRefundResult:
checked_at = refund_time or utc_now()
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing_result = await db.execute(
select(CreditRecord).where(
CreditRecord.user_id == user_id,
CreditRecord.refund_for_biz_key == refund_for_biz_key,
CreditRecord.refund_kind.in_(["available", "expired"]),
)
)
existing_records = list(existing_result.scalars().all())
if existing_records:
available = sum((to_credit_decimal(item.balance_delta) for item in existing_records), Decimal("0.00"))
expired = sum((to_credit_decimal(item.expired_amount) for item in existing_records), Decimal("0.00"))
attach_credit_snapshot(user, before)
return CreditRefundResult(
records=tuple(existing_records),
created=False,
total_amount=available + expired,
available_amount=available,
expired_amount=expired,
balance_before=before,
balance_after=before,
)
original_result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == refund_for_biz_key)
.limit(1)
)
original = original_result.scalar_one_or_none()
if original is None:
raise ValueError("未找到原积分消费流水")
allocations = await _load_original_consume_allocations(db, original_record_id=original.id)
balance_ids = list(dict.fromkeys(item.credit_balance_id for item in allocations))
balances_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids), UserCreditBalance.user_id == user_id)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balance_map = {item.id: item for item in balances_result.scalars().all()}
available_total = Decimal("0.00")
expired_total = Decimal("0.00")
available_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
expired_allocations: list[tuple[CreditRecordAllocation, UserCreditBalance, Decimal, Decimal, Decimal, Decimal, Decimal]] = []
for allocation_source in allocations:
allocation = allocation_source.original_allocation
balance = balance_map.get(allocation_source.credit_balance_id)
if balance is None:
raise RuntimeError(f"原积分来源不存在: {allocation_source.credit_balance_id}")
refund_amount = to_credit_decimal(allocation_source.amount)
before_unspent = to_credit_decimal(balance.unspent_amount)
before_consumed = to_credit_decimal(balance.consumed_amount)
if before_consumed < refund_amount:
raise RuntimeError("原积分批次已消费金额不足以退款")
after_consumed = before_consumed - refund_amount
balance.consumed_amount = after_consumed
if checked_at < balance.expires_at and balance.revoked_at is None:
after_unspent = before_unspent + refund_amount
balance.unspent_amount = after_unspent
balance.status = CreditBalanceStatus.ACTIVE.value
available_total += refund_amount
available_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
else:
before_expired = to_credit_decimal(balance.expired_amount)
balance.expired_amount = before_expired + refund_amount
after_unspent = before_unspent
if balance.unspent_amount == 0 and balance.consumed_amount == 0:
balance.status = CreditBalanceStatus.EXPIRED.value
expired_total += refund_amount
expired_allocations.append(
(allocation, balance, refund_amount, before_unspent, after_unspent, before_consumed, after_consumed)
)
meta_kwargs = await _record_meta_kwargs(db, user, record_meta)
created_records: list[CreditRecord] = []
balance_after = before + available_total
async def create_refund_record(kind: str, amount: Decimal, expired_amount: Decimal) -> CreditRecord:
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REFUND.value,
amount=amount,
balance_delta=(amount if kind == "available" else Decimal("0.00")),
expired_amount=expired_amount,
balance_after=balance_after,
description=(
f"{description},退回有效积分:{to_float(amount)}"
if kind == "available"
else f"{description},原积分已过期:{to_float(expired_amount)}"
),
related_id=related_id,
request_time=checked_at,
biz_key=f"{biz_key or refund_for_biz_key + ':refund'}:{kind}",
refund_for_biz_key=refund_for_biz_key,
refund_kind=kind,
**meta_kwargs,
)
db.add(record)
await db.flush()
return record
if available_total > 0:
available_record = await create_refund_record("available", available_total, Decimal("0.00"))
created_records.append(available_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=available_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_AVAILABLE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in available_allocations
]
)
if expired_total > 0:
expired_record = await create_refund_record("expired", expired_total, expired_total)
created_records.append(expired_record)
db.add_all(
[
CreditRecordAllocation(
id=generate_id(),
credit_record_id=expired_record.id,
credit_balance_id=balance.id,
user_id=user_id,
source_allocation_id=allocation.id,
allocation_action=CreditAllocationAction.REFUND_EXPIRED.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=after_unspent,
consumed_before=before_consumed,
consumed_after=after_consumed,
)
for allocation, balance, amount, before_unspent, after_unspent, before_consumed, after_consumed in expired_allocations
]
)
attach_credit_snapshot(user, balance_after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_SOURCE_REFUNDED",
event_status="success",
source="app.services.credit.ledger_service.refund_consumption",
user_id=user_id,
task_id=related_id,
message="按原积分来源完成业务失败退款",
detail={
"refund_for_biz_key": refund_for_biz_key,
"available_refund": to_float(available_total),
"expired_refund": to_float(expired_total),
"source_count": len(allocations),
"created_record_ids": [item.id for item in created_records],
"refund_time": checked_at.isoformat(),
},
)
return CreditRefundResult(
records=tuple(created_records),
created=True,
total_amount=available_total + expired_total,
available_amount=available_total,
expired_amount=expired_total,
balance_before=before,
balance_after=balance_after,
)
async def revoke_balances(
db: AsyncSession,
*,
balances: Iterable[UserCreditBalance],
description: str,
related_id: str | None,
biz_key: str,
request_time: datetime | None = None,
) -> CreditMutationResult:
items = list(balances)
if not items:
raise ValueError("没有可撤销的积分")
checked_at = request_time or utc_now()
user_id = items[0].user_id
await acquire_user_credit_lock(db, user_id)
user = await _load_user(db, user_id)
before = await get_available_credits(db, user_id, request_time=checked_at)
existing = await _find_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
attach_credit_snapshot(user, before)
return CreditMutationResult(user, existing, False, abs(to_float(existing.amount)), to_float(before), to_float(before))
total = Decimal("0.00")
allocation_rows: list[CreditRecordAllocation] = []
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before,
description=description,
related_id=related_id,
request_time=checked_at,
biz_key=biz_key,
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
charge_action=CreditRecordAction.REFUND.value,
)
db.add(record)
await db.flush()
for balance in items:
if balance.user_id != user_id:
raise ValueError("不能跨用户撤销积分")
amount = to_credit_decimal(balance.unspent_amount)
if amount <= 0:
continue
before_unspent = amount
before_consumed = to_credit_decimal(balance.consumed_amount)
balance.unspent_amount = Decimal("0.00")
balance.revoked_amount = to_credit_decimal(balance.revoked_amount) + amount
balance.revoked_at = checked_at
balance.status = CreditBalanceStatus.REVOKED.value
total += amount
allocation_rows.append(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=balance.id,
user_id=user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=amount,
request_time=checked_at,
credit_level_snapshot=balance.credit_level,
source_type_snapshot=balance.source_type,
source_id_snapshot=balance.source_id,
valid_from_snapshot=balance.valid_from,
expires_at_snapshot=balance.expires_at,
unspent_before=before_unspent,
unspent_after=Decimal("0.00"),
consumed_before=before_consumed,
consumed_after=before_consumed,
)
)
after = before - total
record.amount = -total
record.balance_delta = -total
record.balance_after = after
db.add_all(allocation_rows)
attach_credit_snapshot(user, after)
await db.flush()
log_operation_event(
domain="billing",
module="dynamic_credit",
event_type="CREDIT_REVOKED",
event_status="success",
source="app.services.credit.ledger_service.revoke_balances",
user_id=user_id,
task_id=related_id,
message="积分批次撤销完成",
detail={
"record_id": record.id,
"amount": to_float(total),
"balance_count": len(allocation_rows),
"biz_key": biz_key,
},
)
return CreditMutationResult(user, record, True, to_float(total), to_float(before), to_float(after))
@@ -0,0 +1,15 @@
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
async def acquire_user_credit_lock(db: AsyncSession, user_id: str) -> None:
"""PostgreSQL 事务级用户锁;SQLite 调试环境无需额外锁。"""
bind = db.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "postgresql":
await db.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:lock_key, 0))"),
{"lock_key": f"credit:{user_id}"},
)
@@ -0,0 +1,271 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import (
CreditProductType,
ProductPriceType,
SUBSCRIPTION_GRANT_COUNT,
SubscriptionBillingCycle,
)
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.user import User
from app.services.credit.utils import to_credit_decimal, utc_now
@dataclass(slots=True, frozen=True)
class ProductPriceQuote:
product: CreditProduct
purchase_scene: str
price_type: str
base_price: Decimal
activity_price: Decimal | None
target_price: Decimal
deduction_amount: Decimal
payable_amount: Decimal
source_subscription_id: str | None = None
upgrade_period_ids: tuple[str, ...] = ()
def grant_count_for_cycle(cycle: str | None) -> int:
try:
return SUBSCRIPTION_GRANT_COUNT[str(cycle)]
except KeyError as exc:
raise ValueError("不支持的订阅周期") from exc
def activity_price_if_valid(product: CreditProduct, request_time: datetime) -> Decimal | None:
if product.activity_price is None:
return None
if product.activity_start_at is None or product.activity_end_at is None:
return None
if not (product.activity_start_at <= request_time < product.activity_end_at):
return None
return to_credit_decimal(product.activity_price)
def current_product_price(
product: CreditProduct,
*,
first_purchase: bool,
request_time: datetime,
upgrade: bool = False,
) -> tuple[Decimal, Decimal | None, Decimal, str]:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
price = to_credit_decimal(product.price)
return price, None, price, ProductPriceType.REGULAR.value
base = to_credit_decimal(
product.regular_price if upgrade or not first_purchase else product.first_purchase_price
)
activity = activity_price_if_valid(product, request_time)
if activity is not None and activity < base:
return base, activity, activity, ProductPriceType.ACTIVITY.value
return base, activity, base, (
ProductPriceType.UPGRADE.value
if upgrade
else ProductPriceType.FIRST_PURCHASE.value if first_purchase else ProductPriceType.REGULAR.value
)
async def get_active_subscription(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
for_update: bool = False,
) -> UserCreditSubscription | None:
checked_at = request_time or utc_now()
stmt = (
select(UserCreditSubscription)
.where(
UserCreditSubscription.user_id == user_id,
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.start_at <= checked_at,
UserCreditSubscription.expires_at > checked_at,
)
.order_by(UserCreditSubscription.start_at.desc(), UserCreditSubscription.id.desc())
.limit(1)
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_upgrade_deduction_preview(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
request_time: datetime,
) -> Decimal:
if subscription.billing_cycle not in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
return Decimal("0.00")
result = await db.execute(
select(UserCreditSubscriptionPeriod.allocated_paid_amount).where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
)
return sum((to_credit_decimal(value) for value in result.scalars().all()), Decimal("0.00"))
async def list_active_products(db: AsyncSession) -> list[CreditProduct]:
result = await db.execute(
select(CreditProduct)
.where(CreditProduct.is_active.is_(True))
.order_by(CreditProduct.product_type.asc(), CreditProduct.sort_order.asc(), CreditProduct.id.asc())
)
return list(result.scalars().all())
async def get_product(db: AsyncSession, product_id: str, *, for_update: bool = False) -> CreditProduct | None:
stmt = select(CreditProduct).where(CreditProduct.id == product_id).limit(1)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def build_product_catalog(
db: AsyncSession,
*,
user: User,
request_time: datetime | None = None,
) -> dict:
checked_at = request_time or utc_now()
products = await list_active_products(db)
current = await get_active_subscription(db, user.id, request_time=checked_at)
first_purchase = user.first_membership_paid_at is None
subscription_products: list[dict] = []
credit_addons: list[dict] = []
upgrade_deduction = (
await get_upgrade_deduction_preview(db, subscription=current, request_time=checked_at)
if current is not None
else Decimal("0.00")
)
for product in products:
if product.product_type == CreditProductType.CREDIT_ADDON.value:
credit_addons.append(product_to_dict(product, user_price=to_credit_decimal(product.price), price_type=ProductPriceType.REGULAR.value, can_purchase=True))
continue
# 首订资格已经使用后,未开启续费的套餐不返回给客户端。
# 该过滤同时适用于过期后的续费和有效订阅期间的升级入口,
# 避免仅靠客户端隐藏后仍可被直接构造请求购买。
if not first_purchase and not bool(product.renewal_enabled):
continue
can_purchase = current is None
can_upgrade = False
reason = None
if current is not None:
can_upgrade = (
product.billing_cycle == current.billing_cycle
and int(product.tier_rank or 0) > int(current.tier_rank or 0)
)
can_purchase = can_upgrade
if not can_upgrade:
reason = "当前订阅有效,暂不能续费;仅可升级同周期更高等级套餐"
_, _, target_price, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=checked_at,
upgrade=can_upgrade,
)
deduction_amount = upgrade_deduction if can_upgrade else Decimal("0.00")
user_price = max(Decimal("0.00"), target_price - deduction_amount)
if can_upgrade and user_price <= Decimal("0.00"):
can_purchase = False
reason = "当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理"
item = product_to_dict(
product,
user_price=user_price,
price_type=price_type,
can_purchase=can_purchase,
target_price=target_price,
deduction_amount=deduction_amount,
)
item["can_upgrade"] = can_upgrade
item["unavailable_reason"] = reason
subscription_products.append(item)
return {
"subscription_products": subscription_products,
"credit_addons": credit_addons,
"first_purchase_available": first_purchase,
"current_subscription": subscription_to_dict(current) if current else None,
}
def product_to_dict(
product: CreditProduct,
*,
user_price: Decimal | None = None,
price_type: str | None = None,
can_purchase: bool | None = None,
target_price: Decimal | None = None,
deduction_amount: Decimal | None = None,
) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"description": product.description,
"features": product.features_json or [],
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"grant_count": grant_count_for_cycle(product.billing_cycle) if product.is_subscription else 1,
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"activity_start_at": product.activity_start_at,
"activity_end_at": product.activity_end_at,
"renewal_enabled": bool(product.renewal_enabled),
"grant_credits": float(product.grant_credits or 0),
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"price": float(user_price if user_price is not None else product.price),
"current_price": float(user_price if user_price is not None else product.price),
"target_price": float(target_price) if target_price is not None else None,
"deduction_amount": float(deduction_amount or Decimal("0.00")),
"price_type": price_type,
"credit_level": product.credit_level,
"currency": product.currency,
"is_active": product.is_active,
"sort_order": product.sort_order,
"can_purchase": can_purchase,
}
def subscription_to_dict(subscription: UserCreditSubscription) -> dict:
return {
"id": subscription.id,
"product_id": subscription.product_id,
"status": subscription.status,
"purchase_scene": subscription.purchase_scene,
"tier_code": subscription.tier_code,
"tier_rank": subscription.tier_rank,
"billing_cycle": subscription.billing_cycle,
"anchor_at": subscription.anchor_at,
"start_at": subscription.start_at,
"expires_at": subscription.expires_at,
"monthly_grant_credits": float(subscription.monthly_grant_credits_snapshot),
"grant_count": subscription.grant_count,
"granted_count": subscription.granted_count,
}
@@ -0,0 +1,170 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import CreditBalanceStatus
from app.models.credit.balance import UserCreditBalance
from app.services.credit.time_policy import last_usable_at
from app.services.credit.utils import to_credit_decimal, to_float, utc_now
@dataclass(slots=True, frozen=True)
class CreditBalanceSummary:
available_credits: Decimal
next_expiring_credits: Decimal
next_expires_at: datetime | None
next_last_usable_at: datetime | None
def to_dict(self) -> dict:
return {
"available_credits": to_float(self.available_credits),
"credits": to_float(self.available_credits),
"next_expiring_credits": to_float(self.next_expiring_credits),
"next_expires_at": self.next_expires_at,
"next_last_usable_at": self.next_last_usable_at,
}
async def get_available_credits(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> Decimal:
checked_at = request_time or utc_now()
result = await db.execute(
select(func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0)).where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
)
return to_credit_decimal(result.scalar_one())
async def get_balance_summary(
db: AsyncSession,
user_id: str,
*,
request_time: datetime | None = None,
) -> CreditBalanceSummary:
checked_at = request_time or utc_now()
available = await get_available_credits(db, user_id, request_time=checked_at)
expiry_result = await db.execute(
select(
UserCreditBalance.expires_at,
func.sum(UserCreditBalance.unspent_amount).label("amount"),
)
.where(
UserCreditBalance.user_id == user_id,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.expires_at)
.order_by(UserCreditBalance.expires_at.asc())
.limit(1)
)
row = expiry_result.first()
expires_at = row.expires_at if row else None
expiring = to_credit_decimal(row.amount if row else 0)
return CreditBalanceSummary(
available_credits=available,
next_expiring_credits=expiring,
next_expires_at=expires_at,
next_last_usable_at=last_usable_at(expires_at) if expires_at else None,
)
async def get_user_credit_map(
db: AsyncSession,
user_ids: Iterable[str],
*,
request_time: datetime | None = None,
) -> dict[str, float]:
ids = list(dict.fromkeys(str(item) for item in user_ids if item))
if not ids:
return {}
checked_at = request_time or utc_now()
result = await db.execute(
select(
UserCreditBalance.user_id,
func.coalesce(func.sum(UserCreditBalance.unspent_amount), 0).label("credits"),
)
.where(
UserCreditBalance.user_id.in_(ids),
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.revoked_at.is_(None),
)
.group_by(UserCreditBalance.user_id)
)
output = {user_id: 0.0 for user_id in ids}
for row in result:
output[str(row.user_id)] = to_float(row.credits)
return output
def attach_credit_snapshot(user: object, credits: Decimal | float | int) -> object:
# SQLAlchemy Declarative 对象允许附加非映射运行时属性;不会写回 users 表。
setattr(user, "credits", to_float(to_credit_decimal(credits)))
return user
def effective_balance_status(balance: UserCreditBalance, *, request_time: datetime | None = None) -> str:
checked_at = request_time or utc_now()
if balance.revoked_at is not None or to_credit_decimal(balance.revoked_amount) > 0:
return CreditBalanceStatus.REVOKED.value
if balance.expires_at <= checked_at:
return CreditBalanceStatus.EXPIRED.value
if balance.valid_from > checked_at:
return CreditBalanceStatus.SCHEDULED.value
if to_credit_decimal(balance.unspent_amount) <= 0:
return CreditBalanceStatus.CONSUMED.value
return CreditBalanceStatus.ACTIVE.value
def apply_balance_status_filter(stmt, status: str | None, *, request_time: datetime):
if not status:
return stmt
if status == CreditBalanceStatus.REVOKED.value:
return stmt.where(
or_(UserCreditBalance.revoked_at.is_not(None), UserCreditBalance.revoked_amount > 0)
)
base_not_revoked = and_(
UserCreditBalance.revoked_at.is_(None),
UserCreditBalance.revoked_amount <= 0,
)
if status == CreditBalanceStatus.EXPIRED.value:
return stmt.where(base_not_revoked, UserCreditBalance.expires_at <= request_time)
if status == CreditBalanceStatus.SCHEDULED.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from > request_time,
UserCreditBalance.expires_at > request_time,
)
if status == CreditBalanceStatus.CONSUMED.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount <= 0,
)
if status == CreditBalanceStatus.ACTIVE.value:
return stmt.where(
base_not_revoked,
UserCreditBalance.valid_from <= request_time,
UserCreditBalance.expires_at > request_time,
UserCreditBalance.unspent_amount > 0,
)
return stmt.where(UserCreditBalance.status == status)
@@ -0,0 +1,827 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_balance import (
CreditAllocationAction,
CreditBalanceSourceType,
CreditBalanceStatus,
)
from app.enums.credit_product import CreditProductType
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordType
from app.enums.credit_subscription import (
CreditSubscriptionPeriodStatus,
CreditSubscriptionStatus,
)
from app.models.credit.allocation import CreditRecordAllocation
from app.models.credit.balance import UserCreditBalance
from app.models.credit.product import CreditProduct
from app.models.credit.subscription import UserCreditSubscription
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.credit_record import CreditRecord
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.ledger_service import grant_credits
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import grant_count_for_cycle
from app.services.credit.query_service import get_available_credits
from app.services.credit.time_policy import add_natural_months, natural_month_period
from app.services.credit.utils import to_credit_decimal, utc_now
from app.services.operation_log_service import log_operation_event
from app.utils.id_gen import generate_id
def _allocate_amount_by_period(total: Decimal, count: int) -> list[Decimal]:
cents = int((to_credit_decimal(total) * 100).to_integral_value())
base, remainder = divmod(cents, count)
values = [Decimal(base) / 100 for _ in range(count)]
values[-1] += Decimal(remainder) / 100
return [to_credit_decimal(item) for item in values]
def _product_snapshot(product: CreditProduct) -> dict:
return {
"id": product.id,
"product_code": product.product_code,
"product_type": product.product_type,
"name": product.name,
"tier_code": product.tier_code,
"tier_rank": product.tier_rank,
"billing_cycle": product.billing_cycle,
"monthly_grant_credits": float(product.monthly_grant_credits or 0),
"first_purchase_price": float(product.first_purchase_price or 0),
"regular_price": float(product.regular_price or 0),
"activity_price": float(product.activity_price) if product.activity_price is not None else None,
"grant_credits": float(product.grant_credits or 0),
"validity_months": int(product.validity_months or 1) if product.is_credit_addon else None,
"credit_level": product.credit_level,
"features": product.features_json or [],
}
def _resolve_order_product_snapshot(
order: PaymentOrder,
product: CreditProduct | None,
) -> dict:
"""返回支付创建时冻结的商品快照。
支付履约不得读取后台后来修改后的价格积分或套餐周期旧订单若尚未
保存快照才允许使用当前商品生成一次兼容快照
"""
snapshot = dict(order.product_snapshot_json or {})
if not snapshot and product is not None:
snapshot = _product_snapshot(product)
if not snapshot:
raise ValueError("订单缺少商品快照,无法履约")
return snapshot
def _snapshot_decimal(snapshot: dict, key: str) -> Decimal:
return to_credit_decimal(snapshot.get(key) or 0)
def _snapshot_validity_months(snapshot: dict) -> int:
raw_value = snapshot.get("validity_months")
if raw_value is None:
# 兼容历史订单快照:旧版本增值包固定为1个自然月。
return 1
if isinstance(raw_value, bool):
raise ValueError("积分增值包有效期快照无效")
try:
months = int(raw_value)
except (TypeError, ValueError) as exc:
raise ValueError("积分增值包有效期快照无效") from exc
if isinstance(raw_value, float) and not raw_value.is_integer():
raise ValueError("积分增值包有效期快照无效")
if not 1 <= months <= 36:
raise ValueError("积分增值包有效期必须为1-36个月")
return months
async def _create_subscription(
db: AsyncSession,
*,
order: PaymentOrder,
product: CreditProduct | None,
paid_at: datetime,
) -> tuple[UserCreditSubscription, list[UserCreditSubscriptionPeriod]]:
snapshot = _resolve_order_product_snapshot(order, product)
billing_cycle = str(snapshot.get("billing_cycle") or "")
count = grant_count_for_cycle(billing_cycle)
expires_at = add_natural_months(paid_at, count)
# 升级订单的现金实付额已扣除旧套餐未来周期价值;新订阅后续再升级时,
# 周期价值必须按目标套餐完整价格快照分摊,不能只按本次现金实付额分摊。
pricing_basis_amount = to_credit_decimal(
order.target_price_snapshot
if order.purchase_scene == "upgrade" and order.target_price_snapshot is not None
else order.amount
)
subscription = UserCreditSubscription(
id=generate_id(),
user_id=order.user_id,
product_id=order.product_id,
payment_order_id=order.id,
status=CreditSubscriptionStatus.ACTIVE.value,
purchase_scene=str(order.purchase_scene or "renewal"),
tier_code=str(snapshot.get("tier_code") or ""),
tier_rank=int(snapshot.get("tier_rank") or 0),
billing_cycle=billing_cycle,
anchor_at=paid_at,
start_at=paid_at,
expires_at=expires_at,
next_grant_at=(add_natural_months(paid_at, 1) if count > 1 else None),
monthly_grant_credits_snapshot=_snapshot_decimal(snapshot, "monthly_grant_credits"),
grant_count=count,
granted_count=0,
paid_amount_snapshot=pricing_basis_amount,
product_snapshot_json=snapshot,
source_subscription_id=order.source_subscription_id,
upgrade_order_id=(order.id if order.purchase_scene == "upgrade" else None),
)
db.add(subscription)
await db.flush()
allocations = _allocate_amount_by_period(pricing_basis_amount, count)
periods: list[UserCreditSubscriptionPeriod] = []
for sequence in range(count):
start, end = natural_month_period(paid_at, sequence)
period = UserCreditSubscriptionPeriod(
id=generate_id(),
subscription_id=subscription.id,
sequence=sequence + 1,
scheduled_at=start,
valid_from=start,
expires_at=end,
grant_credits=_snapshot_decimal(snapshot, "monthly_grant_credits"),
allocated_paid_amount=allocations[sequence],
status=CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
db.add(period)
periods.append(period)
await db.flush()
return subscription, periods
async def grant_subscription_period(
db: AsyncSession,
*,
subscription: UserCreditSubscription,
period: UserCreditSubscriptionPeriod,
request_time: datetime | None = None,
) -> UserCreditBalance:
checked_at = request_time or utc_now()
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and period.issued_balance_id:
result = await db.execute(
select(UserCreditBalance).where(UserCreditBalance.id == period.issued_balance_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
return existing
if period.status not in {
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}:
raise ValueError("当前订阅周期不能发放积分")
mutation = await grant_credits(
db,
user_id=subscription.user_id,
amount=period.grant_credits,
description=f"订阅套餐第{period.sequence}个月积分发放",
source_type=CreditBalanceSourceType.SUBSCRIPTION_GRANT.value,
valid_from=period.valid_from,
expires_at=period.expires_at,
credit_level=str(subscription.product_snapshot_json.get("credit_level") or "general"),
source_id=period.id,
product_id=subscription.product_id,
payment_order_id=subscription.payment_order_id,
subscription_id=subscription.id,
subscription_period_id=period.id,
related_id=subscription.id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"subscription:{subscription.id}:period:{period.sequence}:grant",
metadata_json={"period_sequence": period.sequence},
request_time=checked_at,
)
result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.grant_record_id == mutation.record.id)
.limit(1)
)
balance = result.scalar_one()
period.status = CreditSubscriptionPeriodStatus.GRANTED.value
period.issued_balance_id = balance.id
period.issued_at = checked_at
subscription.granted_count = max(subscription.granted_count, period.sequence)
subscription.next_grant_at = (
add_natural_months(subscription.anchor_at, period.sequence)
if period.sequence < subscription.grant_count
else None
)
await db.flush()
return balance
async def _reconcile_upgrade_period_mismatch(
db: AsyncSession,
*,
order: PaymentOrder,
new_first_balance: UserCreditBalance,
checked_at: datetime,
) -> None:
"""核对升级订单创建时的抵扣周期与支付结算时状态。
正常情况下抵扣周期一直处于 upgrade_reserved仅取消未来发放
若历史竞态导致周期已发放则撤销未消费积分并把每一笔尚未退款的
消费分摊通过 source_allocation_id 迁移到升级套餐首期积分保证后续
业务失败退款仍能准确退回当前实际承担成本的积分来源
"""
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id.in_(period_ids))
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
periods = list(result.scalars().all())
expected_ids = [str(item) for item in period_ids]
if len(expected_ids) != len(set(expected_ids)):
raise RuntimeError("升级订单抵扣周期快照存在重复ID")
actual_ids = {str(period.id) for period in periods}
if actual_ids != set(expected_ids):
raise RuntimeError("升级订单抵扣周期快照与实际周期集合不一致")
if not order.source_subscription_id:
raise RuntimeError("升级订单缺少原订阅ID")
allowed_statuses = {
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
CreditSubscriptionPeriodStatus.GRANTED.value,
}
for period in periods:
if str(period.subscription_id) != str(order.source_subscription_id):
raise RuntimeError("升级订单抵扣周期不属于原订阅")
if str(period.upgrade_order_id or "") != str(order.id):
raise RuntimeError("升级订单抵扣周期未绑定当前订单")
if period.status not in allowed_statuses:
raise RuntimeError(f"升级订单抵扣周期状态不允许结算:{period.status}")
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value and not period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期已发放但缺少积分来源记录")
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value and period.issued_balance_id:
raise RuntimeError("升级订单抵扣周期仍为预留状态但已存在积分来源记录")
reserved = [
period for period in periods
if period.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
]
inconsistent = [
period
for period in periods
if period.status == CreditSubscriptionPeriodStatus.GRANTED.value
and period.issued_balance_id
]
for period in reserved:
period.status = CreditSubscriptionPeriodStatus.CANCELLED_BY_UPGRADE.value
period.cancelled_at = checked_at
if not inconsistent:
return
balance_ids = [str(period.issued_balance_id) for period in inconsistent if period.issued_balance_id]
balance_result = await db.execute(
select(UserCreditBalance)
.where(UserCreditBalance.id.in_(balance_ids))
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
old_balances = list(balance_result.scalars().all())
if {str(item.id) for item in old_balances} != set(balance_ids):
raise RuntimeError("升级边界补偿缺少原积分批次")
period_by_balance = {str(period.issued_balance_id): period for period in inconsistent}
for balance in old_balances:
period = period_by_balance.get(str(balance.id))
if period is None:
raise RuntimeError("升级边界补偿积分批次与周期无法对应")
if str(balance.user_id) != str(order.user_id):
raise RuntimeError("升级边界补偿积分批次不属于当前用户")
if str(balance.subscription_id or "") != str(order.source_subscription_id):
raise RuntimeError("升级边界补偿积分批次不属于原订阅")
if str(balance.subscription_period_id or "") != str(period.id):
raise RuntimeError("升级边界补偿积分批次与订阅周期不一致")
total_transfer = sum(
(to_credit_decimal(item.consumed_amount) for item in old_balances),
Decimal("0.00"),
)
if to_credit_decimal(new_first_balance.unspent_amount) < total_transfer:
raise RuntimeError("升级套餐首期积分不足以承接边界消费来源迁移")
before_available = await get_available_credits(db, order.user_id, request_time=checked_at)
record = CreditRecord(
id=generate_id(),
user_id=order.user_id,
type=CreditRecordType.REVOKE.value,
amount=Decimal("0.00"),
balance_delta=Decimal("0.00"),
expired_amount=Decimal("0.00"),
balance_after=before_available,
description="套餐升级边界核对:废弃重复发放积分并迁移已消费来源",
related_id=order.id,
request_time=checked_at,
biz_key=f"payment-order:{order.id}:upgrade-reconcile",
billing_scene=CreditRecordBillingScene.CREDIT_REVOKE.value,
)
db.add(record)
await db.flush()
new_unspent_cursor = to_credit_decimal(new_first_balance.unspent_amount)
new_consumed_cursor = to_credit_decimal(new_first_balance.consumed_amount)
total_removed = Decimal("0.00")
for old in old_balances:
old_unspent = to_credit_decimal(old.unspent_amount)
old_consumed = to_credit_decimal(old.consumed_amount)
total_removed += old_unspent + old_consumed
if old_unspent > 0:
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
allocation_action=CreditAllocationAction.REVOKE.value,
amount=old_unspent,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=old_unspent,
unspent_after=Decimal("0.00"),
consumed_before=old_consumed,
consumed_after=old_consumed,
)
)
if old_consumed > 0:
consume_result = await db.execute(
select(CreditRecordAllocation)
.where(
CreditRecordAllocation.credit_balance_id == old.id,
CreditRecordAllocation.allocation_action
== CreditAllocationAction.CONSUME.value,
)
.order_by(CreditRecordAllocation.created_at.asc(), CreditRecordAllocation.id.asc())
)
original_allocations = list(consume_result.scalars().all())
original_ids = [item.id for item in original_allocations]
adjusted_by_source: dict[str, Decimal] = {}
if original_ids:
adjusted_result = await db.execute(
select(CreditRecordAllocation).where(
CreditRecordAllocation.source_allocation_id.in_(original_ids),
CreditRecordAllocation.allocation_action.in_(
[
CreditAllocationAction.REFUND_AVAILABLE.value,
CreditAllocationAction.REFUND_EXPIRED.value,
CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
]
),
)
)
for adjusted in adjusted_result.scalars().all():
if adjusted.source_allocation_id:
adjusted_by_source[adjusted.source_allocation_id] = (
adjusted_by_source.get(adjusted.source_allocation_id, Decimal("0.00"))
+ to_credit_decimal(adjusted.amount)
)
remaining = old_consumed
old_consumed_cursor = old_consumed
for original_allocation in original_allocations:
if remaining <= 0:
break
original_amount = to_credit_decimal(original_allocation.amount)
already_adjusted = adjusted_by_source.get(original_allocation.id, Decimal("0.00"))
available_to_transfer = original_amount - already_adjusted
if available_to_transfer < 0:
raise RuntimeError("消费分摊的退款或迁移金额超过原消费金额")
transfer_amount = min(available_to_transfer, remaining)
if transfer_amount <= 0:
continue
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=old.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_OUT.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=old.credit_level,
source_type_snapshot=old.source_type,
source_id_snapshot=old.source_id,
valid_from_snapshot=old.valid_from,
expires_at_snapshot=old.expires_at,
unspent_before=Decimal("0.00"),
unspent_after=Decimal("0.00"),
consumed_before=old_consumed_cursor,
consumed_after=old_consumed_cursor - transfer_amount,
)
)
db.add(
CreditRecordAllocation(
id=generate_id(),
credit_record_id=record.id,
credit_balance_id=new_first_balance.id,
user_id=order.user_id,
source_allocation_id=original_allocation.id,
allocation_action=CreditAllocationAction.UPGRADE_SOURCE_TRANSFER_IN.value,
amount=transfer_amount,
request_time=checked_at,
credit_level_snapshot=new_first_balance.credit_level,
source_type_snapshot=new_first_balance.source_type,
source_id_snapshot=new_first_balance.source_id,
valid_from_snapshot=new_first_balance.valid_from,
expires_at_snapshot=new_first_balance.expires_at,
unspent_before=new_unspent_cursor,
unspent_after=new_unspent_cursor - transfer_amount,
consumed_before=new_consumed_cursor,
consumed_after=new_consumed_cursor + transfer_amount,
)
)
old_consumed_cursor -= transfer_amount
new_unspent_cursor -= transfer_amount
new_consumed_cursor += transfer_amount
remaining -= transfer_amount
if remaining > 0:
raise RuntimeError("无法定位全部已消费积分的原始业务分摊,升级结算已中止")
old.unspent_amount = Decimal("0.00")
old.consumed_amount = Decimal("0.00")
old.revoked_amount = to_credit_decimal(old.revoked_amount) + old_unspent + old_consumed
old.revoked_at = checked_at
old.status = CreditBalanceStatus.REVOKED.value
for period in inconsistent:
if period.issued_balance_id == old.id:
period.status = CreditSubscriptionPeriodStatus.REVOKED_BY_UPGRADE.value
period.revoked_at = checked_at
new_first_balance.unspent_amount = new_unspent_cursor
new_first_balance.consumed_amount = new_consumed_cursor
if new_unspent_cursor == 0:
new_first_balance.status = CreditBalanceStatus.CONSUMED.value
record.amount = -total_removed
record.balance_delta = -total_removed
record.balance_after = before_available - total_removed
await db.flush()
async def fulfill_payment_product(
db: AsyncSession,
*,
order: PaymentOrder,
fulfilled_at: datetime | None = None,
) -> None:
if order.fulfillment_status == "fulfilled":
return
checked_at = fulfilled_at or order.paid_at or utc_now()
await acquire_user_credit_lock(db, order.user_id)
user_result = await db.execute(select(User).where(User.id == order.user_id).with_for_update().limit(1))
user = user_result.scalar_one()
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == order.product_id).limit(1)
)
product = product_result.scalar_one_or_none()
snapshot = _resolve_order_product_snapshot(order, product)
product_type = str(order.product_type or snapshot.get("product_type") or "")
if product_type == CreditProductType.CREDIT_ADDON.value:
await grant_credits(
db,
user_id=order.user_id,
amount=_snapshot_decimal(snapshot, "grant_credits"),
description=f"购买积分增值包:{order.product_name_snapshot or snapshot.get('name') or '积分增值包'}",
source_type=CreditBalanceSourceType.CREDIT_ADDON.value,
valid_from=checked_at,
expires_at=add_natural_months(checked_at, _snapshot_validity_months(snapshot)),
credit_level=str(snapshot.get("credit_level") or "general"),
source_id=order.id,
product_id=order.product_id,
payment_order_id=order.id,
related_id=order.id,
biz_key=f"payment-order:{order.id}:credit-addon:grant",
request_time=checked_at,
)
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
return
old_subscription: UserCreditSubscription | None = None
if order.purchase_scene == "upgrade":
if not order.source_subscription_id:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
old_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.source_subscription_id)
.with_for_update()
.limit(1)
)
old_subscription = old_result.scalar_one_or_none()
if old_subscription is None or str(old_subscription.user_id) != str(order.user_id):
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
return
if order.purchase_scene != "upgrade":
# 首充/续费维持原有支付履约语义:创建或发放异常继续向外抛出,
# 不能被升级专用的核对失败状态吞掉。
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
else:
try:
async with db.begin_nested():
subscription, periods = await _create_subscription(
db,
order=order,
product=product,
paid_at=checked_at,
)
first_balance = await grant_subscription_period(
db,
subscription=subscription,
period=periods[0],
request_time=checked_at,
)
await _reconcile_upgrade_period_mismatch(
db,
order=order,
new_first_balance=first_balance,
checked_at=checked_at,
)
except Exception as exc:
order.fulfillment_status = CreditSubscriptionStatus.UPGRADE_RECONCILE_FAILED.value
await db.flush()
log_operation_event(
domain="billing",
module="credit_upgrade",
event_type="CREDIT_UPGRADE_RECONCILE_FAILED",
event_status="failed",
source="app.services.credit.subscription_service.fulfill_payment_product",
user_id=str(order.user_id),
task_id=str(order.id),
message="套餐升级支付履约周期核对失败",
error=str(exc),
detail={
"order_id": str(order.id),
"order_no": str(order.order_no),
"source_subscription_id": str(order.source_subscription_id or ""),
"upgrade_period_ids": list(order.upgrade_period_ids_json or []),
},
)
return
if old_subscription is not None:
old_subscription.status = CreditSubscriptionStatus.UPGRADED.value
old_subscription.upgrade_order_id = order.id
order.subscription_id = subscription.id
order.fulfillment_status = "fulfilled"
order.fulfilled_at = checked_at
if user.first_membership_paid_at is None:
user.first_membership_paid_at = checked_at
await db.flush()
async def list_due_subscription_period_candidates(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 100,
) -> list[tuple[str, str, str]]:
result = await db.execute(
select(
UserCreditSubscriptionPeriod.id,
UserCreditSubscriptionPeriod.subscription_id,
UserCreditSubscription.user_id,
)
.join(
UserCreditSubscription,
UserCreditSubscription.id == UserCreditSubscriptionPeriod.subscription_id,
)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
UserCreditSubscriptionPeriod.scheduled_at <= request_time,
)
.order_by(UserCreditSubscriptionPeriod.scheduled_at.asc(), UserCreditSubscriptionPeriod.id.asc())
.limit(max(1, batch_size))
)
return [(str(row.id), str(row.subscription_id), str(row.user_id)) for row in result.all()]
async def grant_due_subscription_period_by_id(
db: AsyncSession,
*,
period_id: str,
subscription_id: str,
user_id: str,
request_time: datetime,
) -> bool:
await acquire_user_credit_lock(db, user_id)
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = subscription_result.scalar_one_or_none()
if subscription is None or subscription.status != CreditSubscriptionStatus.ACTIVE.value:
return False
period_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(UserCreditSubscriptionPeriod.id == period_id)
.with_for_update()
.limit(1)
)
period = period_result.scalar_one_or_none()
if (
period is None
or period.subscription_id != subscription.id
or period.status != CreditSubscriptionPeriodStatus.SCHEDULED.value
or period.scheduled_at > request_time
):
return False
await grant_subscription_period(
db,
subscription=subscription,
period=period,
request_time=request_time,
)
return True
async def grant_due_subscription_periods(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 100,
) -> int:
checked_at = request_time or utc_now()
candidates = await list_due_subscription_period_candidates(
db, request_time=checked_at, batch_size=batch_size
)
count = 0
for period_id, subscription_id, user_id in candidates:
if await grant_due_subscription_period_by_id(
db,
period_id=period_id,
subscription_id=subscription_id,
user_id=user_id,
request_time=checked_at,
):
count += 1
return count
async def list_due_subscription_ids(
db: AsyncSession,
*,
request_time: datetime,
batch_size: int = 500,
) -> list[str]:
result = await db.execute(
select(UserCreditSubscription.id)
.where(
UserCreditSubscription.status == CreditSubscriptionStatus.ACTIVE.value,
UserCreditSubscription.expires_at <= request_time,
)
.order_by(UserCreditSubscription.expires_at.asc(), UserCreditSubscription.id.asc())
.limit(max(1, batch_size))
)
return [str(item) for item in result.scalars().all()]
async def expire_subscription_by_id(
db: AsyncSession,
*,
subscription_id: str,
request_time: datetime,
) -> bool:
result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == subscription_id)
.with_for_update()
.limit(1)
)
subscription = result.scalar_one_or_none()
if (
subscription is None
or subscription.status != CreditSubscriptionStatus.ACTIVE.value
or subscription.expires_at > request_time
):
return False
subscription.status = CreditSubscriptionStatus.EXPIRED.value
subscription.next_grant_at = None
await db.flush()
return True
async def expire_due_subscriptions(
db: AsyncSession,
*,
request_time: datetime | None = None,
batch_size: int = 500,
) -> int:
checked_at = request_time or utc_now()
ids = await list_due_subscription_ids(db, request_time=checked_at, batch_size=batch_size)
count = 0
for subscription_id in ids:
if await expire_subscription_by_id(
db, subscription_id=subscription_id, request_time=checked_at
):
count += 1
return count
async def revoke_payment_order_credits(
db: AsyncSession,
*,
order: PaymentOrder,
reason: str,
request_time: datetime | None = None,
) -> float:
"""支付现有退款流程的积分账本适配:只撤销该订单当前尚未消费的积分。"""
from app.services.credit.ledger_service import revoke_balances
checked_at = request_time or utc_now()
await acquire_user_credit_lock(db, order.user_id)
balance_result = await db.execute(
select(UserCreditBalance)
.where(
UserCreditBalance.payment_order_id == order.id,
UserCreditBalance.user_id == order.user_id,
UserCreditBalance.unspent_amount > 0,
UserCreditBalance.valid_from <= checked_at,
UserCreditBalance.expires_at > checked_at,
UserCreditBalance.revoked_at.is_(None),
)
.order_by(UserCreditBalance.id.asc())
.with_for_update()
)
balances = list(balance_result.scalars().all())
revoked = sum((to_credit_decimal(item.unspent_amount) for item in balances), Decimal("0.00"))
if balances:
await revoke_balances(
db,
balances=balances,
description=f"{reason}:撤销订单未消费积分",
related_id=order.id,
biz_key=f"payment-order:{order.id}:refund-revoke",
request_time=checked_at,
)
if order.subscription_id:
subscription_result = await db.execute(
select(UserCreditSubscription)
.where(UserCreditSubscription.id == order.subscription_id)
.limit(1)
.with_for_update()
)
subscription = subscription_result.scalar_one_or_none()
if subscription:
subscription.status = CreditSubscriptionStatus.REFUNDED.value
periods_result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == subscription.id,
UserCreditSubscriptionPeriod.status.in_([
CreditSubscriptionPeriodStatus.SCHEDULED.value,
CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
]),
)
.order_by(UserCreditSubscriptionPeriod.id.asc())
.with_for_update()
)
for period in periods_result.scalars().all():
period.status = CreditSubscriptionPeriodStatus.CANCELLED.value
period.cancelled_at = checked_at
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return float(revoked)
@@ -0,0 +1,39 @@
from __future__ import annotations
import calendar
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.services.credit.utils import ensure_aware
BUSINESS_TZ = ZoneInfo("Asia/Shanghai")
def to_business_time(value: datetime) -> datetime:
return ensure_aware(value).astimezone(BUSINESS_TZ)
def add_natural_months(anchor_at: datetime, months: int) -> datetime:
"""始终基于传入锚点计算自然月,月底压缩后下月恢复原锚点日。"""
anchor = to_business_time(anchor_at)
month_index = anchor.year * 12 + anchor.month - 1 + int(months)
year, month_zero = divmod(month_index, 12)
month = month_zero + 1
target_day = min(anchor.day, calendar.monthrange(year, month)[1])
return anchor.replace(year=year, month=month, day=target_day)
def natural_month_period(anchor_at: datetime, sequence: int) -> tuple[datetime, datetime]:
start = add_natural_months(anchor_at, sequence)
end = add_natural_months(anchor_at, sequence + 1)
return start, end
def next_local_midnight(value: datetime) -> datetime:
local = to_business_time(value)
next_day = local.date() + timedelta(days=1)
return datetime.combine(next_day, time.min, tzinfo=BUSINESS_TZ)
def last_usable_at(expires_at: datetime) -> datetime:
return ensure_aware(expires_at) - timedelta(microseconds=1)
@@ -0,0 +1,163 @@
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.credit_product import SubscriptionBillingCycle
from app.enums.credit_subscription import CreditSubscriptionPeriodStatus
from app.models.credit.product import CreditProduct
from app.models.credit.subscription_period import UserCreditSubscriptionPeriod
from app.models.payment_order import PaymentOrder
from app.models.user import User
from app.services.credit.locking import acquire_user_credit_lock
from app.services.credit.product_service import (
ProductPriceQuote,
current_product_price,
get_active_subscription,
)
from app.services.credit.utils import to_credit_decimal
async def quote_and_reserve_product_purchase(
db: AsyncSession,
*,
user: User,
product: CreditProduct,
order_id: str,
request_time: datetime,
) -> ProductPriceQuote:
if product.is_credit_addon:
price = to_credit_decimal(product.price)
return ProductPriceQuote(
product=product,
purchase_scene="credit_addon",
price_type="regular",
base_price=price,
activity_price=None,
target_price=price,
deduction_amount=Decimal("0.00"),
payable_amount=price,
)
# 所有订阅购买/升级先取得统一用户积分事务锁,再锁订阅和周期,
# 与月度发放、支付履约保持同一锁顺序,避免升级边界死锁。
await acquire_user_credit_lock(db, user.id)
pending_result = await db.execute(
select(PaymentOrder.id).where(
PaymentOrder.user_id == user.id,
PaymentOrder.id != order_id,
PaymentOrder.status == "pending",
PaymentOrder.product_type == "subscription",
).limit(1)
)
if pending_result.scalar_one_or_none() is not None:
raise ValueError("已有待支付的订阅或升级订单,请先完成或等待订单过期")
current = await get_active_subscription(
db,
user.id,
request_time=request_time,
for_update=True,
)
first_purchase = user.first_membership_paid_at is None
if not first_purchase and not bool(product.renewal_enabled):
raise ValueError("该订阅套餐暂未开放续费或升级")
if current is None:
base, activity, target, price_type = current_product_price(
product,
first_purchase=first_purchase,
request_time=request_time,
upgrade=False,
)
return ProductPriceQuote(
product=product,
purchase_scene="first_purchase" if first_purchase else "renewal",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=Decimal("0.00"),
payable_amount=target,
)
if product.billing_cycle != current.billing_cycle:
raise ValueError("当前订阅有效,只能升级同周期更高等级套餐")
if int(product.tier_rank or 0) <= int(current.tier_rank or 0):
raise ValueError("当前订阅有效,不能提前续费或降级")
base, activity, target, price_type = current_product_price(
product,
first_purchase=False,
request_time=request_time,
upgrade=True,
)
period_ids: list[str] = []
periods: list[UserCreditSubscriptionPeriod] = []
deduction = Decimal("0.00")
if current.billing_cycle in {
SubscriptionBillingCycle.QUARTERLY.value,
SubscriptionBillingCycle.YEARLY.value,
}:
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.subscription_id == current.id,
UserCreditSubscriptionPeriod.scheduled_at > request_time,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.SCHEDULED.value,
)
.order_by(UserCreditSubscriptionPeriod.sequence.asc())
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
deduction += to_credit_decimal(period.allocated_paid_amount)
period_ids.append(period.id)
payable = target - deduction
if payable <= Decimal("0.00"):
raise ValueError("当前升级抵扣金额已达到或超过目标套餐价格,暂不支持0元升级,请联系客服处理")
for period in periods:
period.status = CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value
period.upgrade_order_id = order_id
period.reserved_at = request_time
return ProductPriceQuote(
product=product,
purchase_scene="upgrade",
price_type=price_type,
base_price=base,
activity_price=activity,
target_price=target,
deduction_amount=deduction,
payable_amount=payable,
source_subscription_id=current.id,
upgrade_period_ids=tuple(period_ids),
)
async def release_upgrade_reservation(
db: AsyncSession,
*,
order: PaymentOrder,
released_at: datetime,
) -> int:
period_ids = list(order.upgrade_period_ids_json or [])
if not period_ids:
return 0
await acquire_user_credit_lock(db, order.user_id)
result = await db.execute(
select(UserCreditSubscriptionPeriod)
.where(
UserCreditSubscriptionPeriod.id.in_(period_ids),
UserCreditSubscriptionPeriod.upgrade_order_id == order.id,
UserCreditSubscriptionPeriod.status == CreditSubscriptionPeriodStatus.UPGRADE_RESERVED.value,
)
.with_for_update()
)
periods = list(result.scalars().all())
for period in periods:
period.status = CreditSubscriptionPeriodStatus.SCHEDULED.value
period.upgrade_order_id = None
period.reserved_at = None
await db.flush()
return len(periods)
@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Any
CREDIT_QUANT = Decimal("0.01")
def to_credit_decimal(value: Any) -> Decimal:
try:
return Decimal(str(value or 0)).quantize(CREDIT_QUANT, rounding=ROUND_HALF_UP)
except Exception as exc:
raise ValueError(f"无效积分数值: {value!r}") from exc
def to_float(value: Decimal | int | float | None) -> float:
return float(to_credit_decimal(value))
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value
+114 -187
View File
@@ -1,5 +1,4 @@
import math
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,23 +8,6 @@ from app.models.credit_record import CreditRecord
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError
from app.enums.common import BillingBlockEventEnum
from app.services.operation_log_service import log_operation_event
from app.services.system_config_cache import get_system_config_value
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""Calculate text credits based on actual token usage and cached configurable rate."""
raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens")
try:
rate = float(raw_rate) if raw_rate not in (None, "") else 1.0
except (TypeError, ValueError):
rate = 1.0
total_tokens = input_tokens + output_tokens
return round(total_tokens * rate / 1000, 2)
async def _get_credit_ratio(
@@ -176,31 +158,19 @@ async def calc_image_credits(
return round(total, 2)
@dataclass(slots=True)
class CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
async def _get_existing_credit_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
"""按正式业务幂等键查找已有积分流水。"""
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.services.credit.ledger_service import (
CreditMutationResult,
deduct_credits as deduct_dynamic_credits,
grant_credits,
refund_consumption,
)
from app.services.credit.query_service import get_available_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_float, utc_now
from app.services.credit_record_meta_service import CreditRecordMeta
async def deduct_credits_result(
@@ -216,94 +186,21 @@ async def deduct_credits_result(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分扣减"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0 and not create_zero_record:
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
)
if amount > 0 and not allow_negative and before_balance < amount:
event_type = (
BillingBlockEventEnum.NEGATIVE_BALANCE.value
if before_balance < 0
else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value
)
log_operation_event(
domain="billing",
module="credits",
event_type=event_type,
event_status="failed",
source="app.services.credits.deduct_credits_result",
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效"""
return await deduct_dynamic_credits(
db,
user_id=user_id,
task_id=related_id,
message="积分不足,已拦截新的扣费请求",
detail={
"user_id": user_id,
"amount": amount,
"before_balance": before_balance,
"allow_negative": allow_negative,
"biz_key": biz_key,
"refund_for_biz_key": refund_for_biz_key,
"description": description,
"record_type": record_type,
},
)
raise InsufficientCreditsError()
user.credits = round(before_balance - max(0.0, amount), 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=-amount,
balance_after=user.credits,
amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
record_meta=record_meta,
record_type=record_type,
create_zero_record=create_zero_record,
request_time=request_time,
)
@@ -320,9 +217,10 @@ async def deduct_credits(
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
request_time: datetime | None = None,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。"""
mutation = await deduct_credits_result(
return (
await deduct_credits_result(
db,
user_id=user_id,
amount=amount,
@@ -334,8 +232,9 @@ async def deduct_credits(
record_type=record_type,
allow_negative=allow_negative,
create_zero_record=create_zero_record,
request_time=request_time,
)
return mutation.user
).user
async def add_credits_result(
@@ -349,66 +248,63 @@ async def add_credits_result(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
request_time: datetime | None = None,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分增加。"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0:
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
)
user.credits = round(before_balance + amount, 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
checked_at = request_time or utc_now()
if record_type == CreditRecordType.REFUND.value and refund_for_biz_key:
refunded = await refund_consumption(
db,
user_id=user_id,
type=record_type,
amount=amount,
balance_after=user.credits,
refund_for_biz_key=refund_for_biz_key,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
record_meta=record_meta,
refund_time=checked_at,
)
db.add(record)
await db.flush()
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = user_result.scalar_one()
setattr(user, "credits", to_float(refunded.balance_after))
record = refunded.records[0] if refunded.records else None
return CreditMutationResult(
user=user,
record=record,
created=True,
created=refunded.created,
amount=to_float(refunded.total_amount),
balance_before=to_float(refunded.balance_before),
balance_after=to_float(refunded.balance_after),
refund_available=to_float(refunded.available_amount),
refund_expired=to_float(refunded.expired_amount),
)
starts_at = valid_from or checked_at
return await grant_credits(
db,
user_id=user_id,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
description=description,
source_type=source_type,
valid_from=starts_at,
expires_at=expires_at or add_natural_months(starts_at, 1),
credit_level=credit_level,
source_id=source_id or related_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
related_id=related_id,
record_type=record_type,
biz_key=biz_key,
record_meta=record_meta,
request_time=checked_at,
)
@@ -423,9 +319,19 @@ async def add_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
valid_from: datetime | None = None,
expires_at: datetime | None = None,
credit_level: str = CreditLevel.GENERAL.value,
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
source_id: str | None = None,
product_id: str | None = None,
payment_order_id: str | None = None,
subscription_id: str | None = None,
subscription_period_id: str | None = None,
request_time: datetime | None = None,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。"""
mutation = await add_credits_result(
return (
await add_credits_result(
db,
user_id=user_id,
amount=amount,
@@ -435,8 +341,18 @@ async def add_credits(
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
valid_from=valid_from,
expires_at=expires_at,
credit_level=credit_level,
source_type=source_type,
source_id=source_id,
product_id=product_id,
payment_order_id=payment_order_id,
subscription_id=subscription_id,
subscription_period_id=subscription_period_id,
request_time=request_time,
)
return mutation.user
).user
async def refund_credits(
@@ -449,30 +365,41 @@ async def refund_credits(
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
request_time: datetime | None = None,
) -> User:
"""生成失败积分回退。"""
if not refund_for_biz_key:
raise ValueError("动态积分退款必须指定原消费 biz_key")
return await add_credits(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type="refund",
record_type=CreditRecordType.REFUND.value,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
request_time=request_time,
)
async def get_records(db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20) -> tuple[list[CreditRecord], int]:
async def get_credit_balance(db: AsyncSession, user_id: str, request_time: datetime | None = None) -> float:
return to_float(await get_available_credits(db, user_id, request_time=request_time or utc_now()))
async def get_records(
db: AsyncSession,
user_id: str,
page: int = 1,
page_size: int = 20,
) -> tuple[list[CreditRecord], int]:
count_query = select(func.count(CreditRecord.id)).where(CreditRecord.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
.order_by(CreditRecord.created_at.desc())
.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
return list(result.scalars().all()), int(total)
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from dataclasses import asdict, dataclass
from typing import Any, Mapping
from typing import Any
from sqlalchemy import select
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.generation_record import GenerationRecord
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 (
CreditRecordMeta,
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.utils.id_gen import generate_id
from app.services.credits import calc_image_credits, calc_video_credits, deduct_credits_result
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_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value
OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.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(
r"^(?P<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|refund|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)
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(
*,
owner_type: str,
@@ -101,8 +78,8 @@ def build_credit_biz_key(
owner_id = owner_id.strip()
charge_kind = charge_kind.strip()
action = action.strip()
if action not in ("charge", "refund", "hold", "hold_release"):
raise ValueError("action 仅支持 charge/refund/hold/hold_release")
if action not in ("charge", "llm_charge", "refund", "pre_deduct", "hold", "hold_release"):
raise ValueError("action 仅支持 charge/llm_charge/refund/pre_deduct/hold/hold_release")
if attempt_no <= 0:
raise ValueError("attempt_no 必须大于 0")
return f"{owner_type}:{owner_id}:attempt:{attempt_no}:{charge_kind}:{action}"
@@ -119,27 +96,6 @@ def parse_credit_biz_key(biz_key: str | None) -> dict[str, Any] | None:
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(
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(
db: AsyncSession,
*,
@@ -28,7 +28,6 @@ from app.enums.generation_status import (
GenerationStatus,
GenerationType,
)
from app.enums.llm_billing import LlmBillingConfigKey
from app.models.generation_record import GenerationRecord
from app.models.project import Project
from app.schemas.generation import OptimizeParams
@@ -51,12 +50,12 @@ from app.services.generation.pipeline.generation_record_config_service import (
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
finalize_llm_business_failure,
mark_business_success,
charge_llm_credits,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
@@ -110,7 +109,6 @@ def _billing_context(*, user_id: str, record_id: str, request_id: str | None) ->
billing_scene=CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
related_id=record_id,
hold_config_key=LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
description_prefix="AI创作提示词优化",
trace_id=f"generation-optimize:{record_id}",
request_id=request_id,
@@ -246,14 +244,14 @@ async def _settle_staged_result(
detail={"status": status_snapshot, "attempt_no": _PROMPT_ATTEMPT_NO},
)
try:
billing = await settle_success(
billing = await mark_business_success(
db,
ctx,
usage=usage,
description=f"提示词优化 - {project_name}",
)
charge_item = next(
(item for item in billing.items if item.biz_key == ctx.charge_biz_key),
(item for item in billing.items if item.biz_key == ctx.billing_biz_key),
None,
)
record.text_credits_cost = round(float(charge_item.amount if charge_item else 0.0), 2)
@@ -468,7 +466,7 @@ async def optimize_generation_prompt(
ctx = _billing_context(user_id=user_id, record_id=record_id, request_id=req.idempotency_key)
try:
await start_hold(db, ctx)
await charge_llm_credits(db, ctx)
await db.commit()
except Exception:
await db.rollback()
@@ -488,7 +486,7 @@ async def optimize_generation_prompt(
},
)
log_provider_start(ctx, detail={"gen_type": req.gen_type.value})
await log_provider_start(db, ctx, detail={"gen_type": req.gen_type.value})
try:
optimized_prompt, usage = await optimize_prompt(
db,
@@ -507,11 +505,15 @@ async def optimize_generation_prompt(
log_owner_type=OWNER_GENERATION_RECORD,
log_owner_id=record_id,
generation_attempt_no=_PROMPT_ATTEMPT_NO,
fixed_model_config_id=ctx.model_config_id,
fixed_model_snapshot=ctx.model_parameters_snapshot,
)
log_provider_success(ctx, usage=usage)
await log_provider_success(db, ctx, usage=usage)
except Exception as exc:
await db.rollback()
log_provider_failure(ctx, error=str(exc))
provider_succeeded, recovered_usage = await record_provider_exception(db, ctx, exc)
if recovered_usage:
usage = recovered_usage
compensated = False
try:
failed_result = await db.execute(
@@ -521,12 +523,21 @@ async def optimize_generation_prompt(
.limit(1)
)
failed_record = failed_result.scalar_one_or_none()
business_already_succeeded = bool(
failed_record is not None
and failed_record.status in {
GenerationStatus.prompt_optimized.value,
GenerationStatus.generating.value,
GenerationStatus.completed.value,
}
)
if failed_record is not None and failed_record.status == GenerationStatus.optimizing.value:
failed_record.status = GenerationStatus.failed.value
failed_record.error_message = extract_error_message(exc, "提示词")
await release_on_failure(db, ctx, error=str(exc))
compensated = True
await db.commit()
if not business_already_succeeded:
await finalize_llm_business_failure(ctx, error=str(exc))
compensated = True
except Exception:
await db.rollback()
logger.exception("prompt optimize failure compensation failed: record_id=%s", record_id)
@@ -613,6 +624,7 @@ async def optimize_generation_prompt(
await db.rollback()
logger.exception("prompt optimize provider result staging failed: record_id=%s", record_id)
if not staged:
failure = last_stage_error or RuntimeError("unknown staging failure")
log_operation_error(
domain=_LOG_DOMAIN,
event_type=GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED.value,
@@ -622,9 +634,45 @@ async def optimize_generation_prompt(
project_id=project_id,
task_id=record_id,
detail={"attempt_no": _PROMPT_ATTEMPT_NO, "stage": "provider_result_persistence"},
exc=last_stage_error or RuntimeError("unknown staging failure"),
exc=failure,
)
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,请联系管理员根据模型日志处理")
# 供应商调用和 Token 已由独立调用审计事务保存。结果连续暂存失败后,
# 当前 API 已没有可继续恢复的本地业务结果,必须先结束业务状态,再按
# 原积分来源退回场景积分消费,不能让账务永久停留在 processing。
business_already_succeeded = False
try:
await db.rollback()
failed_result = await db.execute(
select(GenerationRecord)
.where(
GenerationRecord.id == record_id,
GenerationRecord.user_id == user_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
failed_record = failed_result.scalar_one_or_none()
business_already_succeeded = bool(
failed_record is not None
and failed_record.status in {
GenerationStatus.prompt_optimized.value,
GenerationStatus.generating.value,
GenerationStatus.completed.value,
}
)
if failed_record is not None and not business_already_succeeded:
failed_record.status = GenerationStatus.failed.value
failed_record.pipeline_stage = None
failed_record.error_message = "提词已生成但本地结果暂存失败"
await db.commit()
except Exception:
await db.rollback()
logger.exception("prompt optimize staging final failure persistence failed: record_id=%s", record_id)
if business_already_succeeded:
return PromptOptimizeServiceResult(record_id=record_id, idempotent=True)
await finalize_llm_business_failure(ctx, error=str(failure))
raise HTTPException(status_code=503, detail="提词已生成但本地暂存失败,场景消费积分已按原来源退回")
_log_event(
GenerationRecordEventTypeEnum.PROMPT_OPTIMIZE_PROVIDER_RESULT_STAGED,
@@ -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[: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,
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,
}
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.hot_opening_replicate import HotOpeningGenerationModeEnum, HotOpeningStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -55,13 +54,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
ensure_hold_exists,
ensure_llm_charged,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
refund_on_final_failure,
finalize_llm_business_failure,
mark_business_success,
charge_llm_credits,
)
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
@@ -892,7 +893,7 @@ async def submit_image_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -906,9 +907,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -1005,14 +1005,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="爆款开头复刻图片AI提词优化",
trace_id=f"hot-opening-image-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1023,7 +1022,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -1048,9 +1047,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1064,16 +1065,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
# Provider 已成功,旧步骤即使失效也必须按真实 usage 结算,不能免费释放。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1121,21 +1116,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1150,16 +1143,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1178,16 +1162,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-图片AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1351,7 +1327,7 @@ async def submit_video_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1365,9 +1341,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1462,14 +1437,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="爆款开头复刻视频AI提词优化",
trace_id=f"hot-opening-video-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1480,7 +1454,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "",
@@ -1517,9 +1491,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"hot-video-prompt:{step_id_value}",
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1533,15 +1509,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1592,21 +1563,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1621,16 +1590,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1649,16 +1609,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="爆款开头复刻-视频AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1946,7 +1898,7 @@ async def mark_hot_opening_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, HotOpeningStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
await release_on_failure(
await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1964,11 +1916,6 @@ async def mark_hot_opening_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"爆款开头复刻图片AI提词优化"
if step.step_code == HotOpeningStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -15,6 +15,7 @@ from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRemoteActionEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_VERSION,
@@ -1545,8 +1546,15 @@ def _log_video_prompt_ai_event(
**common,
)
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(select(ModelConfig).where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None)).order_by(ModelConfig.priority.desc()).limit(1))
async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
stmt = select(ModelConfig).where(
ModelConfig.is_active == True,
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
if model_config_id:
stmt = stmt.where(ModelConfig.id == model_config_id)
result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -1566,6 +1574,8 @@ async def optimize_hot_opening_video_prompt(
project_id: str | None = None,
step_id: str | None = None,
trace_id: str | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
call_id = generate_id()
duration = int(video_config["duration"])
@@ -1580,15 +1590,18 @@ async def optimize_hot_opening_video_prompt(
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
config_row = await _select_model_config(db)
config_row = await _select_model_config(db, fixed_model_config_id)
snapshot = dict(fixed_model_snapshot or {})
config = (
SimpleNamespace(
id=str(config_row.id),
name=str(config_row.name or ""),
provider=str(config_row.provider or ""),
api_base=str(config_row.api_base or ""),
name=str(snapshot.get("name") or config_row.name or ""),
provider=str(snapshot.get("provider") or config_row.provider or ""),
api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
max_tokens=snapshot.get("max_tokens"),
temperature=snapshot.get("temperature"),
)
if config_row is not None
else None
@@ -1597,14 +1610,7 @@ async def optimize_hot_opening_video_prompt(
# 只读事务,后续文件读取/Base64 转换及远程请求不能占用数据库连接。
await db.rollback()
if not config:
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
return result, build_final_video_prompt(result), {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0,
"usage_reported": True,
"billing_free": True,
}
raise RuntimeError("固定LLM模型不存在、已停用或为Mock模型,禁止自动切换和降级")
if use_base64:
video_url_final = await media_to_base64(material_video_url, "video/mp4")
@@ -1720,11 +1726,8 @@ async def optimize_hot_opening_video_prompt(
try:
data = response.json()
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("视频提词模型响应 content 为空")
except Exception as exc:
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
_log_video_prompt_ai_event(
call_id=call_id,
module=module,
@@ -1740,7 +1743,7 @@ async def optimize_hot_opening_video_prompt(
response_data=response_data,
http_status=response.status_code,
remote_request_id=remote_request_id,
message="视频提词模型响应解析失败",
message="视频提词模型响应 JSON 解析失败",
error=str(exc),
)
raise
@@ -1765,6 +1768,9 @@ async def optimize_hot_opening_video_prompt(
})
try:
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise RuntimeError("视频提词模型响应 content 为空")
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
except Exception as exc:
@@ -1787,7 +1793,7 @@ async def optimize_hot_opening_video_prompt(
message="视频提词业务 JSON 解析失败",
error=str(exc),
)
raise
raise LlmProviderPostprocessError(f"视频提词响应后处理失败: {exc}", usage=token_usage) from exc
success_event, remote_action = _video_prompt_remote_event(module, success=True)
_log_video_prompt_ai_event(
call_id=call_id,
+40 -57
View File
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.model_config import ModelConfig
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
@@ -74,39 +75,37 @@ async def optimize_prompt(
log_owner_type: str | None = None,
log_owner_id: str | None = None,
generation_attempt_no: int | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict | None = None,
) -> tuple[str, dict]:
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
.order_by(ModelConfig.priority.desc())
stmt = select(ModelConfig).where(
ModelConfig.is_active.is_(True),
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
configs = [
SimpleNamespace(
if fixed_model_config_id:
stmt = stmt.where(ModelConfig.id == fixed_model_config_id)
stmt = stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1)
result = await db.execute(stmt)
item = result.scalar_one_or_none()
if item is None:
raise LLMProviderCallError("没有可用的固定LLM模型,本版本禁止Mock、自动切换和降级")
snapshot = dict(fixed_model_snapshot or {})
selected = SimpleNamespace(
id=item.id,
name=item.name,
provider=item.provider,
api_base=item.api_base,
name=snapshot.get("name") or item.name,
provider=snapshot.get("provider") or item.provider,
api_base=snapshot.get("api_base") or item.api_base,
api_key=item.api_key,
model_name=item.model_name,
max_tokens=item.max_tokens,
temperature=item.temperature,
model_name=snapshot.get("model_name") or item.model_name,
max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else item.max_tokens,
temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else item.temperature,
)
for item in result.scalars().all()
]
# Release the read transaction before the external LLM request. Only
# plain scalar snapshots are used afterwards, so expire_on_commit does
# not trigger an ORM refresh while the provider request is in flight.
await db.commit()
if configs:
# 按 priority 从大到小依次尝试,跳过 mock,失败则用下一个
for selected in configs:
if selected.provider == "mock":
continue
if selected.provider in ("openai_compatible", "sdk"):
try:
if selected.provider not in ("openai_compatible", "sdk"):
raise LLMProviderCallError(f"固定模型供应商不受支持:{selected.provider}")
return await _call_openai_compatible(
selected, original_prompt, db, user_id, industry_key, duration,
references=references,
@@ -122,18 +121,6 @@ async def optimize_prompt(
log_owner_id=log_owner_id,
generation_attempt_no=generation_attempt_no,
)
except LLMProviderCallError:
continue
# 所有真实模型都失败,降级到 mock
mock_cfg = next((c for c in configs if c.provider == "mock"), None)
if mock_cfg:
return _mock_optimize(original_prompt, gen_type)
if settings.LLM_MOCK:
return _mock_optimize(original_prompt, gen_type)
return _get_default_prompt(original_prompt, gen_type)
def _mock_optimize(prompt: str, gen_type: str = "video") -> tuple[str, dict]:
@@ -435,19 +422,28 @@ async def _call_openai_compatible(
)
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
try:
raw_usage = data.get("usage")
raw_usage = data.get("usage") if isinstance(data, dict) else None
usage_reported = bool(
isinstance(raw_usage, dict)
and any(
key in raw_usage
for key in ("prompt_tokens", "completion_tokens", "total_tokens")
)
and any(key in raw_usage for key in ("prompt_tokens", "completion_tokens", "total_tokens"))
)
usage = raw_usage if isinstance(raw_usage, dict) else {}
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
output_tokens = int(usage.get("completion_tokens", 0) or 0)
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
token_usage = {
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
"source_module": log_module,
"source_step_code": log_step,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"usage_reported": usage_reported,
}
try:
content = data["choices"][0]["message"]["content"].strip()
if not content:
raise ValueError("模型未返回有效提示词")
@@ -461,18 +457,5 @@ async def _call_openai_compatible(
error=str(exc),
**common_log,
)
raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
token_usage = {
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
"source_module": log_module,
"source_step_code": log_step,
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total_tokens,
"usage_reported": usage_reported,
}
raise LlmProviderPostprocessError(f"模型响应解析失败: {exc}", usage=token_usage) from exc
return content, token_usage
@@ -3,11 +3,13 @@ from app.services.llm_billing.context import (
LlmBillingContext,
LlmBillingPolicy,
LlmBillingStateError,
LlmHoldResult,
LlmHoldValidation,
LlmChargeResult,
LlmChargeValidation,
)
from app.services.llm_billing.service import (
ensure_hold_exists,
charge_llm_credits,
ensure_llm_charged,
finalize_llm_business_failure,
get_llm_ledger_states,
log_celery_dispatch_compensated,
log_celery_dispatch_failure,
@@ -16,9 +18,9 @@ from app.services.llm_billing.service import (
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
mark_business_success,
record_provider_exception,
refund_on_final_failure,
validate_retryable_previous_attempt,
)
@@ -27,19 +29,21 @@ __all__ = [
"LlmBillingContext",
"LlmBillingPolicy",
"LlmBillingStateError",
"LlmHoldResult",
"LlmHoldValidation",
"start_hold",
"ensure_hold_exists",
"LlmChargeResult",
"LlmChargeValidation",
"charge_llm_credits",
"ensure_llm_charged",
"get_llm_ledger_states",
"validate_retryable_previous_attempt",
"log_provider_start",
"log_provider_success",
"log_provider_failure",
"record_provider_exception",
"log_celery_dispatch_start",
"log_celery_dispatch_success",
"log_celery_dispatch_failure",
"log_celery_dispatch_compensated",
"settle_success",
"release_on_failure",
"mark_business_success",
"refund_on_final_failure",
"finalize_llm_business_failure",
]
@@ -0,0 +1,305 @@
from __future__ import annotations
from typing import Any, Mapping
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingExecutionStatus, LlmCallAttemptStatus
from app.models.base import AsyncSessionLocal
from app.models.credit_record import CreditRecord
from app.models.llm_billing.call_attempt import LlmCallAttempt
from app.models.llm_billing.execution import LlmBillingExecution
from app.models.token_usage import TokenUsage
from app.services.credit.utils import utc_now
from app.services.llm_billing.context import LlmBillingContext, LlmBillingStateError
from app.services.operation_log_service import log_operation_event
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:
for key in keys:
value = (usage or {}).get(key)
if value is not None:
try:
return max(0, int(value))
except (TypeError, ValueError):
pass
return 0
async def create_call_attempt(
ctx: LlmBillingContext,
*,
detail: Mapping[str, Any] | None = None,
) -> str:
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == ctx.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = result.scalar_one_or_none()
if execution is None:
raise LlmBillingStateError("LLM积分消费执行记录不存在,禁止调用模型")
if execution.model_config_id and ctx.model_config_id and execution.model_config_id != ctx.model_config_id:
raise LlmBillingStateError("自动重试模型与首次选定模型不一致,已拦截降级/切换")
if execution.status not in {
LlmBillingExecutionStatus.CHARGED.value,
LlmBillingExecutionStatus.PRE_DEDUCTED.value, # 历史兼容
LlmBillingExecutionStatus.PROCESSING.value,
}:
raise LlmBillingStateError(f"LLM执行状态不允许调用模型:{execution.status}")
max_result = await db.execute(
select(func.coalesce(func.max(LlmCallAttempt.call_sequence), 0)).where(
LlmCallAttempt.billing_execution_id == execution.id
)
)
sequence = int(max_result.scalar_one() or 0) + 1
attempt = LlmCallAttempt(
id=generate_id(),
billing_execution_id=execution.id,
call_sequence=sequence,
retry_sequence=max(0, sequence - 1),
model_config_id=execution.model_config_id or ctx.model_config_id,
model_name_snapshot=execution.model_name_snapshot or ctx.model_name,
provider_snapshot=execution.provider_snapshot or ctx.provider,
request_started_at=utc_now(),
status=LlmCallAttemptStatus.STARTED.value,
postprocess_status="pending",
)
db.add(attempt)
execution.status = LlmBillingExecutionStatus.PROCESSING.value
execution.total_call_count += 1
ctx.current_call_attempt_id = attempt.id
# 同一个业务 execution 可能有多次供应商调用;每个新调用必须清空上一调用的
# 成功事实和用量快照,避免下一次真实 provider failure 被误判为后处理失败。
ctx.provider_call_succeeded = False
ctx.provider_usage_snapshot = None
ctx.token_usage_id = None
return attempt.id
async def _get_or_create_token_usage(
db: AsyncSession,
*,
ctx: LlmBillingContext,
attempt: LlmCallAttempt,
usage: Mapping[str, Any] | None,
input_tokens: int,
output_tokens: int,
total_tokens: int,
) -> tuple[TokenUsage, bool]:
"""先持久化 TokenUsage,再允许任何外键引用它。
LlmCallAttempt 仅保存 token_usage_id 字符串没有 ORM relationshipSQLAlchemy
无法仅凭字符串赋值推导 INSERT/UPDATE 顺序所以必须显式 flush TokenUsage
同时按 user_id + biz_key 复用记录保证审计重试幂等
"""
biz_key = f"llm-call:{attempt.id}"
existing_result = await db.execute(
select(TokenUsage)
.where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
.limit(1)
)
existing = existing_result.scalar_one_or_none()
if existing is not None:
return existing, False
usage_row = TokenUsage(
id=generate_id(),
model_config_id=(usage or {}).get("model_config_id") or attempt.model_config_id,
user_id=ctx.user_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
owner_type=ctx.owner_type,
owner_id=ctx.owner_id,
biz_key=biz_key,
source_module=ctx.source_module,
source_step_code=ctx.source_step_code,
)
try:
async with db.begin_nested():
db.add(usage_row)
# 关键:先确保 token_usage 已 INSERT,后续 attempt.token_usage_id UPDATE
# 才不会违反 llm_call_attempts_token_usage_id_fkey。
await db.flush([usage_row])
except IntegrityError:
# 并发或审计重试可能已经创建同一 biz_key,回查复用。
existing_result = await db.execute(
select(TokenUsage)
.where(TokenUsage.user_id == ctx.user_id, TokenUsage.biz_key == biz_key)
.limit(1)
)
existing = existing_result.scalar_one_or_none()
if existing is None:
raise
return existing, False
return usage_row, True
async def finish_call_success(
ctx: LlmBillingContext,
*,
usage: Mapping[str, Any] | None = None,
) -> None:
if not ctx.current_call_attempt_id:
await create_call_attempt(ctx)
now = utc_now()
input_tokens = _usage_int(usage, "input_tokens", "prompt_tokens")
output_tokens = _usage_int(usage, "output_tokens", "completion_tokens")
total_tokens = _usage_int(usage, "total_tokens") or input_tokens + output_tokens
token_usage_id: str | None = None
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmCallAttempt)
.where(LlmCallAttempt.id == ctx.current_call_attempt_id)
.limit(1)
.with_for_update()
)
attempt = result.scalar_one_or_none()
if attempt is None:
raise LlmBillingStateError("LLM调用审计记录不存在")
if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
ctx.token_usage_id = attempt.token_usage_id
return
previous_status = attempt.status
usage_row, token_usage_created = await _get_or_create_token_usage(
db,
ctx=ctx,
attempt=attempt,
usage=usage,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
token_usage_id = usage_row.id
attempt.status = LlmCallAttemptStatus.SUCCEEDED.value
attempt.response_received_at = now
attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
attempt.input_tokens = input_tokens
attempt.output_tokens = output_tokens
attempt.total_tokens = total_tokens
attempt.token_usage_id = token_usage_id
attempt.provider_request_id = (usage or {}).get("provider_request_id") or (usage or {}).get("request_id")
try:
attempt.http_status = int((usage or {}).get("http_status")) if (usage or {}).get("http_status") is not None else None
except (TypeError, ValueError):
attempt.http_status = None
# 支持把此前因审计异常误记的 failed 调用恢复为真实 succeeded。
attempt.error_message = None
attempt.token_unavailable_reason = None
execution_result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == attempt.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = execution_result.scalar_one()
if previous_status in {LlmCallAttemptStatus.FAILED.value, LlmCallAttemptStatus.TIMEOUT.value}:
execution.failed_call_count = max(0, int(execution.failed_call_count or 0) - 1)
execution.successful_call_count += 1
execution.total_input_tokens += input_tokens
execution.total_output_tokens += output_tokens
execution.total_tokens += total_tokens
record_result = await db.execute(
select(CreditRecord)
.where(CreditRecord.id == execution.credit_record_id)
.limit(1)
.with_for_update()
)
record = record_result.scalar_one_or_none()
if record:
record.input_tokens = execution.total_input_tokens
record.output_tokens = execution.total_output_tokens
record.total_tokens = execution.total_tokens
record.llm_call_count = execution.total_call_count
record.llm_success_call_count = execution.successful_call_count
record.llm_failed_call_count = execution.failed_call_count
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:
if not ctx.current_call_attempt_id:
await create_call_attempt(ctx)
now = utc_now()
async with AsyncSessionLocal() as db:
async with db.begin():
result = await db.execute(
select(LlmCallAttempt)
.where(LlmCallAttempt.id == ctx.current_call_attempt_id)
.limit(1)
.with_for_update()
)
attempt = result.scalar_one_or_none()
if attempt is None:
return "missing"
if attempt.status == LlmCallAttemptStatus.SUCCEEDED.value:
# 供应商已成功并已记录 Token;后续解析/校验/落库失败只能记为后处理失败,
# 不能覆盖真实供应商成功事实,也不能重复累计失败调用次数。
attempt.postprocess_status = "failed"
attempt.postprocess_error = str(error)[:5000]
return "postprocess_failure"
if attempt.status != LlmCallAttemptStatus.STARTED.value:
return "noop"
attempt.status = LlmCallAttemptStatus.TIMEOUT.value if status == "timeout" else LlmCallAttemptStatus.FAILED.value
attempt.response_received_at = now
attempt.duration_ms = max(0, int((now - attempt.request_started_at).total_seconds() * 1000))
attempt.error_message = str(error)[:5000]
attempt.token_unavailable_reason = "供应商调用失败,未返回Token使用量"
execution_result = await db.execute(
select(LlmBillingExecution)
.where(LlmBillingExecution.id == attempt.billing_execution_id)
.limit(1)
.with_for_update()
)
execution = execution_result.scalar_one()
execution.failed_call_count += 1
record_result = await db.execute(select(CreditRecord).where(CreditRecord.id == execution.credit_record_id).limit(1).with_for_update())
record = record_result.scalar_one_or_none()
if record:
record.llm_call_count = execution.total_call_count
record.llm_success_call_count = execution.successful_call_count
record.llm_failed_call_count = execution.failed_call_count
return "provider_failure"
+36 -129
View File
@@ -1,145 +1,52 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.llm_billing import LlmBillingConfigKey
from app.models.llm_billing.policy import LlmBillingPolicyModel
from app.services.llm_billing.context import LlmBillingPolicy
from app.services.system_config_cache import get_system_config_values
_DEFAULT_HOLD_CREDITS = 5.0
_FALSE_VALUES = {"0", "false", "no", "off", "disabled"}
_HOLD_CONFIG_KEYS = {
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
}
def _parse_bool(value: str | None, *, default: bool = True) -> bool:
if value is None or str(value).strip() == "":
return default
return str(value).strip().lower() not in _FALSE_VALUES
def _parse_float(value: str | float | int | None) -> float | None:
try:
if value is None or str(value).strip() == "":
return None
return round(float(value), 2)
except (TypeError, ValueError):
return None
def is_llm_hold_config_key(key: str | None) -> bool:
return bool(key and key in _HOLD_CONFIG_KEYS)
async def get_llm_billing_policy(
db: AsyncSession,
*,
config_key: str | None = None,
explicit_hold_credits: float | None = None,
default: float = _DEFAULT_HOLD_CREDITS,
scene_code: str | None = None,
) -> LlmBillingPolicy:
keys = [LlmBillingConfigKey.ENABLED.value]
if config_key:
keys.append(config_key)
keys.extend(
[
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.LEGACY_OPTIMIZE_HOLD.value,
]
)
# 去重并保持优先级;一次读取避免 enabled/scene/default 分散查询。
ordered_keys = list(dict.fromkeys(keys))
values = await get_system_config_values(db, ordered_keys)
enabled = _parse_bool(values.get(LlmBillingConfigKey.ENABLED.value), default=True)
if not enabled:
return LlmBillingPolicy(enabled=False, hold_credits=0.0, config_key=config_key)
if explicit_hold_credits is not None:
amount = _parse_float(explicit_hold_credits)
source_key = "explicit"
else:
amount = None
source_key = None
for key in ordered_keys[1:]:
parsed = _parse_float(values.get(key))
if parsed is not None:
amount = parsed
source_key = key
break
if amount is None:
amount = round(float(default), 2)
source_key = "default"
if amount is None or amount <= 0:
"""按业务场景读取固定消费积分;不再读取旧 SystemConfig 冻结/预扣配置。"""
resolved_scene = scene_code
if not resolved_scene:
return LlmBillingPolicy(
enabled=True,
hold_credits=float(amount or 0),
config_key=config_key,
source_key=source_key,
charge_credits=0.0,
valid=False,
error="启用LLM统一计费时,预扣积分必须大于0",
error="LLM业务场景不能为空",
)
result = await db.execute(
select(LlmBillingPolicyModel)
.where(LlmBillingPolicyModel.scene_code == resolved_scene)
.limit(1)
)
model = result.scalar_one_or_none()
if model is None:
return LlmBillingPolicy(
charge_credits=0.0,
valid=False,
error=f"未配置LLM场景消费积分:{resolved_scene}",
)
# 数据库物理字段 pre_deduct_credits 为历史命名,本轮不迁移字段;业务语义统一为 charge_credits。
amount = float(model.pre_deduct_credits)
if not model.is_active or amount <= 0:
return LlmBillingPolicy(
charge_credits=amount,
valid=False,
error=f"LLM场景积分配置未启用或金额无效:{resolved_scene}",
policy_id=model.id,
version=model.version,
scene_name=model.scene_name,
)
return LlmBillingPolicy(
enabled=True,
hold_credits=round(float(amount), 2),
config_key=config_key,
source_key=source_key,
charge_credits=round(amount, 2),
valid=True,
policy_id=model.id,
version=model.version,
scene_name=model.scene_name,
)
async def get_llm_hold_credits(
db: AsyncSession,
*,
config_key: str | None = None,
default: float = _DEFAULT_HOLD_CREDITS,
) -> float:
policy = await get_llm_billing_policy(db, config_key=config_key, default=default)
return policy.hold_credits
async def is_llm_billing_enabled(db: AsyncSession) -> bool:
return (await get_llm_billing_policy(db)).enabled
async def validate_llm_system_config_value(
db: AsyncSession,
*,
key: str,
value: str,
) -> None:
"""校验后台单项更新,避免启用计费时保存零或负数预扣。"""
if key == LlmBillingConfigKey.ENABLED.value:
if not _parse_bool(value, default=True):
return
keys = [
LlmBillingConfigKey.HOLD_DEFAULT.value,
LlmBillingConfigKey.HOLD_GENERATION_RECORD_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
]
values = await get_system_config_values(db, keys, ttl_seconds=1)
invalid = [
config_name
for config_name in keys
if (raw_value := values.get(config_name)) is not None
and str(raw_value).strip() != ""
and ((parsed := _parse_float(raw_value)) is None or parsed <= 0)
]
if invalid:
raise ValueError(f"启用LLM统一计费前,请先将以下预扣配置设置为大于0:{', '.join(invalid)}")
return
if not is_llm_hold_config_key(key):
return
parsed = _parse_float(value)
enabled_values = await get_system_config_values(db, [LlmBillingConfigKey.ENABLED.value], ttl_seconds=1)
enabled = _parse_bool(enabled_values.get(LlmBillingConfigKey.ENABLED.value), default=True)
if enabled and (parsed is None or parsed <= 0):
raise ValueError("启用LLM统一计费时,预扣积分必须大于0")
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from app.enums.credit_record import CreditRecordChargeKind
from app.enums.llm_billing import LlmBillingLedgerState
@@ -8,25 +9,29 @@ from app.services.generation.billing_service import build_credit_biz_key
class LlmBillingConfigurationError(RuntimeError):
"""LLM 统一账务配置无效,必须在调用模型前终止"""
"""LLM 场景积分配置无效"""
class LlmBillingStateError(RuntimeError):
"""当前 attempt 的账务流水状态不允许继续执行。"""
"""业务 attempt 的 LLM 积分消费状态不允许继续执行。"""
class LlmProviderPostprocessError(RuntimeError):
"""供应商请求已成功并返回用量,但响应内容或业务结构处理失败。"""
def __init__(self, message: str, *, usage: dict | None = None) -> None:
super().__init__(message)
self.usage = dict(usage or {})
@dataclass(slots=True, frozen=True)
class LlmBillingPolicy:
enabled: bool
hold_credits: float
config_key: str | None = None
source_key: str | None = None
charge_credits: float
valid: bool = True
error: str | None = None
@property
def bypassed(self) -> bool:
return not self.enabled
policy_id: str | None = None
version: int | None = None
scene_name: str | None = None
@dataclass(slots=True)
@@ -42,35 +47,22 @@ class LlmBillingContext:
source_step_id: str | None = None
source_step_code: str | None = None
related_id: str | None = None
hold_credits: float | None = None
hold_config_key: str | None = None
description_prefix: str = "LLM"
trace_id: str | None = None
request_id: str | None = None
celery_task_id: str | None = None
provider: str | None = None
model_name: str | None = None
model_config_id: str | None = None
model_parameters_snapshot: dict | None = None
token_usage_id: str | None = None
@property
def hold_biz_key(self) -> str:
return build_credit_biz_key(
owner_type=self.owner_type,
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
action="hold",
)
@property
def hold_release_biz_key(self) -> str:
return build_credit_biz_key(
owner_type=self.owner_type,
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
action="hold_release",
)
# 供应商响应成功事实先于审计落库设置。即使审计事务暂时失败,
# 后续异常处理也不能把真实供应商成功覆盖成 provider failed。
provider_call_succeeded: bool = False
provider_usage_snapshot: dict | None = None
request_time: datetime | None = None
billing_execution_id: str | None = None
current_call_attempt_id: str | None = None
@property
def charge_biz_key(self) -> str:
@@ -79,35 +71,41 @@ class LlmBillingContext:
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
action="charge",
# 使用独立 llm_charge 幂等键,避免与历史 Token 按量计费的 :charge 流水碰撞;
# CreditRecord 的业务动作仍然记录为 charge(真实消费)。
action="llm_charge",
)
@property
def ledger_biz_keys(self) -> tuple[str, str, str]:
return self.hold_biz_key, self.hold_release_biz_key, self.charge_biz_key
def refund_biz_key(self) -> str:
return build_credit_biz_key(
owner_type=self.owner_type,
owner_id=self.owner_id,
attempt_no=self.attempt_no,
charge_kind=self.charge_kind,
action="refund",
)
@property
def billing_biz_key(self) -> str:
return self.charge_biz_key
@dataclass(slots=True, frozen=True)
class LlmHoldResult:
class LlmChargeResult:
amount: float
state: LlmBillingLedgerState
created: bool = False
record_id: str | None = None
reason: str | None = None
@property
def bypassed(self) -> bool:
return self.state == LlmBillingLedgerState.BILLING_BYPASSED
execution_id: str | None = None
@dataclass(slots=True, frozen=True)
class LlmHoldValidation:
class LlmChargeValidation:
can_execute: bool
amount: float
state: LlmBillingLedgerState
reason: str | None = None
hold_record_id: str | None = None
@property
def bypassed(self) -> bool:
return self.state == LlmBillingLedgerState.BILLING_BYPASSED
charge_record_id: str | None = None
execution_id: str | None = None
@@ -0,0 +1,82 @@
from __future__ import annotations
from sqlalchemy import func, or_, select
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.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(
db: AsyncSession,
*,
page: int = 1,
page_size: int = 20,
scene_code: str | None = None,
status: str | None = None,
user_id: str | None = None,
) -> tuple[list[dict], int]:
filters = []
if scene_code:
filters.append(LlmBillingExecution.scene_code == scene_code)
if status:
filters.append(_execution_status_filter(status))
if user_id:
filters.append(LlmBillingExecution.user_id == user_id)
total_result = await db.execute(select(func.count(LlmBillingExecution.id)).where(*filters))
total = int(total_result.scalar_one() or 0)
result = await db.execute(
select(LlmBillingExecution)
.where(*filters)
.order_by(LlmBillingExecution.created_at.desc(), LlmBillingExecution.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
executions = list(result.scalars().all())
# 先收集 execution ids,再一次批查所有 CallAttempt 并按 execution_id 回填,避免 N+1。
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(
select(LlmCallAttempt)
.where(LlmCallAttempt.billing_execution_id.in_(execution_ids))
.order_by(LlmCallAttempt.billing_execution_id, LlmCallAttempt.call_sequence)
)
for call in call_result.scalars().all():
calls_by_execution.setdefault(call.billing_execution_id, []).append(call)
items: list[dict] = []
for execution in executions:
# 数据库物理字段 pre_deduct_credits 为历史命名;Admin/API 统一只暴露 charge_credits。
data = {
column.name: getattr(execution, column.name)
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, [])
items.append(data)
return items, total
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,7 @@ from app.enums.credit_record import (
CreditRecordChargeKind,
CreditRecordOwnerType,
)
from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.hot_opening_replicate import HotOpeningStepCodeEnum, ModuleCodeEnum as HotModuleCodeEnum
from app.enums.shot_replicate import (
ModuleCodeEnum as ShotModuleCodeEnum,
@@ -38,8 +38,7 @@ from app.services.redis_registry_service import (
utc_now,
)
from app.services.celery_runtime.runtime_service import CeleryRuntimeLease, RuntimeIdentity, runtime_lock_values
from app.services.llm_billing import LlmBillingContext, LlmHoldValidation, get_llm_ledger_states
from app.services.llm_billing.config import get_llm_billing_policy
from app.services.llm_billing import LlmBillingContext, LlmChargeValidation, get_llm_ledger_states
from app.tasks.celery_app import celery_app
logger = logging.getLogger("video_gen")
@@ -93,11 +92,6 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if is_image_prompt
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix="模块AI提词优化",
trace_id=f"module-recovery:{step.id}:attempt:{max(1, int(step.version or 1))}",
)
@@ -106,35 +100,24 @@ def _step_llm_billing_context(step: ModuleGenerationStep) -> LlmBillingContext:
async def _load_step_billing_validations(
db: AsyncSession,
steps: Iterable[ModuleGenerationStep],
) -> dict[str, LlmHoldValidation]:
) -> dict[str, LlmChargeValidation]:
step_list = list(steps)
if not step_list:
return {}
contexts = {str(step.id): _step_llm_billing_context(step) for step in step_list}
policies = {}
for config_key in {ctx.hold_config_key for ctx in contexts.values() if ctx.hold_config_key}:
policies[config_key] = await get_llm_billing_policy(db, config_key=config_key)
# 无论当前配置是否关闭,都批量读取历史 attempt 流水:运行中的 active HOLD
# 必须继续结算,不能因后台关闭计费而被当成 bypass 遗留冻结。
# 场景积分消费已经在 API 层完成;恢复任务只认同一业务 attempt 的账务执行记录,
# 不再读取旧 SystemConfig 冻结配置;缺少场景积分消费记录时直接终止恢复。
ledger_states = await get_llm_ledger_states(db, contexts.values())
output: dict[str, LlmHoldValidation] = {}
output: dict[str, LlmChargeValidation] = {}
for step_id, ctx in contexts.items():
policy = policies.get(ctx.hold_config_key)
ledger = ledger_states.get(
ctx.hold_biz_key,
LlmHoldValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
ctx.charge_biz_key,
LlmChargeValidation(False, 0.0, LlmBillingLedgerState.MISSING, "ledger_not_loaded"),
)
if ledger.state == LlmBillingLedgerState.ACTIVE:
output[step_id] = ledger
elif ledger.state == LlmBillingLedgerState.MISSING and policy is not None and policy.bypassed:
output[step_id] = LlmHoldValidation(
True,
0.0,
LlmBillingLedgerState.BILLING_BYPASSED,
"billing_disabled",
)
elif ledger.state == LlmBillingLedgerState.MISSING and (policy is None or not policy.valid):
output[step_id] = LlmHoldValidation(
elif ledger.state == LlmBillingLedgerState.MISSING:
output[step_id] = LlmChargeValidation(
False,
0.0,
LlmBillingLedgerState.INVALID,
@@ -19,7 +19,6 @@ from app.enums.common import (
)
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.generation_task import ChatGenerationTaskStatus
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import (
ShotSegmentReplicateStatusEnum,
ShotSplitStatusEnum,
@@ -51,13 +50,14 @@ from app.services.generation.pipeline.db_lock_service import (
from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.llm_billing import (
LlmBillingContext,
ensure_hold_exists,
ensure_llm_charged,
log_provider_failure,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
mark_business_success,
charge_llm_credits,
record_provider_exception,
finalize_llm_business_failure,
)
from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt,
@@ -369,7 +369,6 @@ def build_v2_video_prompt_billing_context(
source_step_id=str(step_id),
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=str(step_id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{step_id}",
)
@@ -433,7 +432,7 @@ async def create_hot_opening_project_v2(
video_config=video_config,
target_platform=req.target_platform or "抖音",
)
await start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -600,7 +599,7 @@ async def create_shot_replicate_project_v2(
urls=[req.material_image_url],
allow_common_migrate=True,
)
await start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(current_user.id),
@@ -724,7 +723,7 @@ async def rebuild_video_prompt_step_v2(
project.final_video_cover_url = None
project.completed_at = None
project.error_message = None
await start_hold(
await charge_llm_credits(
db,
build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
@@ -799,19 +798,16 @@ async def mark_video_prompt_dispatch_failed_v2(
message=error_message,
detail={"dispatch_compensated": True},
)
await release_on_failure(
db,
build_v2_video_prompt_billing_context(
billing_context = build_v2_video_prompt_billing_context(
user_id=str(project.user_id),
project_id=str(project.id),
step_id=str(step.id),
step_version=int(step.version or 1),
module=config.module,
display_name=config.display_name,
),
error=error_message,
)
await db.commit()
await finalize_llm_business_failure(billing_context, error=error_message)
async def run_video_prompt_optimize_v2(
@@ -888,14 +884,13 @@ async def run_video_prompt_optimize_v2(
source_step_id=project_snapshot["step_id"],
source_step_code=VIDEO_PROMPT_OPTIMIZE,
related_id=project_snapshot["step_id"],
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix=f"{config.display_name}视频提词优化",
trace_id=f"module-v2-video-prompt:{project_snapshot['step_id']}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -906,7 +901,7 @@ async def run_video_prompt_optimize_v2(
provider_succeeded = False
usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video", "flow_version": "v2"})
prompt_schema, final_prompt, usage = await optimize_hot_opening_video_prompt(
db,
user_id=project_snapshot["user_id"],
@@ -921,9 +916,11 @@ async def run_video_prompt_optimize_v2(
module=project_snapshot["module"],
project_id=project_snapshot["project_id"],
step_id=project_snapshot["step_id"],
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=usage)
await log_provider_success(db, llm_billing_context, usage=usage)
if execution_guard is not None:
await execution_guard()
@@ -945,24 +942,20 @@ async def run_video_prompt_optimize_v2(
row = locked.first()
if not row:
await db.rollback()
# Provider 已成功,即使业务对象被异常移除,也必须按真实 usage 完成幂等结算。
await settle_success(
db,
await log_provider_failure(db, llm_billing_context, error="业务对象已失效,供应商结果无法落库")
await finalize_llm_business_failure(
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(业务对象失效结算)",
error="业务对象已失效,供应商结果无法落库",
)
await db.commit()
return None
project, step = row
if int(step.version) != expected_version or step.input_json != expected_input or step.status != ModuleStepStatusEnum.PROCESSING.value:
await settle_success(
db,
await db.rollback()
await log_provider_failure(db, llm_billing_context, error="业务步骤版本已失效,供应商结果被丢弃")
await finalize_llm_business_failure(
llm_billing_context,
usage=usage,
description=f"{config.display_name}-视频提词优化(失效结果结算)",
error="业务步骤版本已失效,供应商结果被丢弃",
)
await db.commit()
log_module_event_file(
module=project_snapshot["module"],
event_type=ModuleEventTypeEnum.STALE_STEP_RESULT_DISCARDED.value,
@@ -973,7 +966,7 @@ async def run_video_prompt_optimize_v2(
detail={"expected_version": expected_version},
)
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=usage,
@@ -1018,33 +1011,24 @@ async def run_video_prompt_optimize_v2(
)
await db.commit()
return step
except DatabaseRowLockBusy:
except DatabaseRowLockBusy as exc:
await db.rollback()
if locals().get("provider_succeeded", False):
await settle_success(
db,
if "llm_billing_context" in locals() and locals().get("provider_succeeded", False):
await log_provider_failure(db, llm_billing_context, error="业务行锁失败,供应商结果无法落库")
await finalize_llm_business_failure(
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(行锁失败结算)",
error="业务行锁失败,供应商结果无法落库",
)
await db.commit()
return None
raise
raise exc
except Exception as exc:
await db.rollback()
if "llm_billing_context" in locals() and not locals().get("provider_succeeded", False):
log_provider_failure(llm_billing_context, error=str(exc))
try:
if "llm_billing_context" in locals():
if locals().get("provider_succeeded", False):
await settle_success(
db,
llm_billing_context,
usage=locals().get("usage") or {},
description=f"{config.display_name}-视频提词优化(本地失败结算)",
)
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
provider_succeeded, usage = await record_provider_exception(db, llm_billing_context, exc)
try:
if execution_guard is not None:
await execution_guard()
result = await execute_with_lock_timeout(
@@ -1070,9 +1054,9 @@ async def run_video_prompt_optimize_v2(
step.completed_at = utc_now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = str(exc) if str(exc) else type(exc).__name__
# provider 已成功时 settle_success 已在当前事务写入 RELEASE/CHARGE
# 即使业务步骤已不存在或已不是 processing,也必须提交账务结算。
await db.commit()
if "llm_billing_context" in locals():
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
log_module_error(
module=row[0].module if row else "module_generation_v2",
event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value,
@@ -1092,8 +1076,11 @@ async def run_video_prompt_optimize_v2(
detail={"origin_error": str(exc), "provider_succeeded": locals().get("provider_succeeded", False)},
exc=mark_exc,
)
if locals().get("provider_succeeded", False):
raise
try:
if "llm_billing_context" in locals():
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
except Exception:
pass
return None
+534 -161
View File
@@ -2,6 +2,7 @@ import logging
import os
import json
from datetime import datetime, timedelta
from enum import Enum
# 尝试设置 SSL 证书路径
try:
@@ -16,6 +17,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.payment_order import PaymentOrder
from app.models.credit.product import CreditProduct
from app.models.user import User
from app.models.system_config import SystemConfig
from app.enums.credit_record import (
CreditRecordAction,
@@ -27,6 +30,10 @@ from app.enums.credit_record import (
)
from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta
from app.services.credits import add_credits, deduct_credits
from app.services.credit.product_service import product_to_dict
from app.services.credit.subscription_service import fulfill_payment_product, revoke_payment_order_credits
from app.services.credit.upgrade_service import quote_and_reserve_product_purchase, release_upgrade_reservation
from app.services.credit.utils import to_credit_decimal, utc_now
from app.utils.id_gen import generate_id, generate_order_no
@@ -109,6 +116,19 @@ if not logger.handlers:
DEFAULT_ORDER_EXPIRE_SECONDS = 180
class PaymentCloseResult(str, Enum):
"""Result of attempting to close a remote payment order.
``PAID`` is intentionally distinct from ``FAILED``: when a gateway
explicitly says an order is already paid, callers must recover the paid
transaction instead of cancelling locally or retrying close forever.
"""
CLOSED = "closed"
PAID = "paid"
FAILED = "failed"
def _get_order_expire_seconds(db_configs: dict[str, str]) -> int:
"""Get order expire time in seconds from config, with fallback to 180."""
try:
@@ -164,71 +184,173 @@ async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
return {c.key: c.value for c in result.scalars().all()}
async def _close_order_for_cancellation(
db: AsyncSession,
order: PaymentOrder,
db_configs: dict[str, str],
) -> PaymentCloseResult:
"""Close a gateway order before changing the local order to cancelled.
A gateway may explicitly report that the order has already been paid.
That is not a close failure: it must be routed into paid-order recovery so
the normal payment fulfillment pipeline can run.
"""
if order.payment_method == "alipay":
closed = await _close_alipay_order(db, order, db_configs)
return PaymentCloseResult.CLOSED if closed else PaymentCloseResult.FAILED
if order.payment_method == "wechat":
return await _close_wechat_order(db, order, db_configs)
return PaymentCloseResult.CLOSED
async def _resolve_order_for_cancellation(
db: AsyncSession,
order: PaymentOrder,
db_configs: dict[str, str],
) -> PaymentCloseResult:
"""Resolve a cancellation attempt into closed / paid / failed.
Normal orders are simply closed. For WeChat ``ORDERPAID``, query only
this order once and feed the verified result into the existing atomic
payment-success handler.
"""
close_result = await _close_order_for_cancellation(db, order, db_configs)
if close_result != PaymentCloseResult.PAID:
return close_result
if order.payment_method != "wechat":
return PaymentCloseResult.FAILED
recovered = await _recover_wechat_paid_order(db, order, db_configs)
return PaymentCloseResult.PAID if recovered else PaymentCloseResult.FAILED
async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
"""If a pending order has passed its expiry, mark it cancelled.
Returns True if the order was expired.
"""Expire one pending order after resolving the remote gateway state.
If WeChat reports ``ORDERPAID``, the order is queried once and recovered
through the normal payment-success pipeline instead of being cancelled.
"""
if order.status != "pending":
return False
db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs)
expiry = order.created_at + timedelta(seconds=expire_seconds)
if datetime.now(order.created_at.tzinfo) >= expiry:
if utc_now() < expiry:
return False
log_order_no = str(order.order_no)
log_user_id = str(order.user_id)
log_amount = order.amount
close_result = await _resolve_order_for_cancellation(db, order, db_configs)
if close_result == PaymentCloseResult.PAID:
logger.info(
f"ORDER_EXPIRE_PAID_RECOVERED order_no={log_order_no} "
f"user={log_user_id} amount={log_amount}"
)
return False
if close_result != PaymentCloseResult.CLOSED:
logger.warning(
f"ORDER_EXPIRE_CLOSE_PENDING order_no={log_order_no} "
f"user={log_user_id} amount={log_amount}"
)
return False
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(db, order=order, released_at=utc_now())
await db.flush()
logger.info(
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
f"amount={order.amount} created_at={order.created_at.isoformat()}"
)
# Also call close API if it was an Alipay or WeChat order
if order.payment_method == "alipay":
try:
await _close_alipay_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {order.order_no}: {e}")
elif order.payment_method == "wechat":
try:
await _close_wechat_order(db, order, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {order.order_no}: {e}")
return True
return False
async def expire_all_pending_orders(db: AsyncSession) -> int:
"""Background task: mark all expired pending orders as cancelled.
Returns the number of orders expired.
"""Background task: resolve and expire overdue pending orders.
Each order is handled in its own database transaction. This keeps one
gateway or fulfillment failure from invalidating ORM state for the rest of
the batch. A WeChat ``ORDERPAID`` result is recovered as a paid order,
never persisted as cancelled.
"""
db_configs = await _get_payment_configs(db)
expire_seconds = _get_order_expire_seconds(db_configs)
threshold = datetime.now() - timedelta(seconds=expire_seconds)
threshold = utc_now() - timedelta(seconds=expire_seconds)
result = await db.execute(
select(PaymentOrder).where(
select(PaymentOrder.order_no).where(
PaymentOrder.status == "pending",
PaymentOrder.created_at <= threshold,
)
)
orders = result.scalars().all()
order_nos = [str(row[0]) for row in result.all()]
# End the read-only candidate transaction before processing individual
# orders. Subsequent commit/rollback never leaves us reusing stale ORM
# instances from this initial scan.
await db.rollback()
expired_count = 0
for o in orders:
o.status = "cancelled"
for order_no in order_nos:
try:
result = await db.execute(
select(PaymentOrder)
.where(
PaymentOrder.order_no == order_no,
PaymentOrder.status == "pending",
)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if order is None:
await db.rollback()
continue
close_result = await _resolve_order_for_cancellation(
db, order, db_configs
)
if close_result == PaymentCloseResult.PAID:
logger.info(
f"ORDER_EXPIRE_PAID_RECOVERED order_no={order_no}"
)
# Paid recovery commits inside process_payment_success_by_order_no.
continue
if close_result != PaymentCloseResult.CLOSED:
logger.warning(
f"ORDER_EXPIRE_CLOSE_PENDING order_no={order_no} "
f"user={order.user_id} amount={order.amount}"
)
await db.rollback()
continue
log_user_id = str(order.user_id)
log_amount = order.amount
order.status = "cancelled"
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
expired_count += 1
logger.info(
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
f"ORDER_EXPIRED order_no={order_no} user={log_user_id} "
f"amount={log_amount}"
)
# Also call close API if it was an Alipay or WeChat order
if o.payment_method == "alipay":
try:
await _close_alipay_order(db, o, db_configs)
except Exception as e:
logger.exception(f"Failed to close Alipay order {o.order_no}: {e}")
elif o.payment_method == "wechat":
try:
await _close_wechat_order(db, o, db_configs)
except Exception as e:
logger.exception(f"Failed to close WeChat order {o.order_no}: {e}")
if orders:
await db.flush()
except Exception:
await db.rollback()
logger.exception(
f"ORDER_EXPIRE_PROCESS_FAILED order_no={order_no}"
)
return expired_count
@@ -291,23 +413,18 @@ def _get_alipay_client(app_id: str, private_key: str, public_key: str, gateway:
async def create_recharge_order(
db: AsyncSession,
user_id: str,
credits: float,
price: float,
label: str,
credits: float = 0.0,
price: float = 0.0,
label: str = "积分商品",
bonus_credits: float = 0.0,
method: str = "wechat",
*,
product_id: str | None = None,
request_time: datetime | None = None,
) -> PaymentOrder:
"""Create a payment order.
Reads payment config from the database (admin panel).
Returns the order; for Alipay the ``qr_url`` attribute will be populated
with the scan-to-pay URL.
"""
# Read config from database first
"""创建支付订单;支付渠道流程保持原样,仅增加积分商品快照和订阅升级预留。"""
db_configs = await _get_payment_configs(db)
mock_mode = _is_mock_mode(db_configs)
# In real mode, validate that the payment method is enabled and configured
if not mock_mode:
enabled_key = f"payment_{method}_enabled"
if db_configs.get(enabled_key, "").lower() != "true":
@@ -321,69 +438,116 @@ async def create_recharge_order(
"payment_wechat_mch_id",
"payment_wechat_private_key",
"payment_wechat_cert_serial_no",
"payment_wechat_api_v3_key"
"payment_wechat_api_v3_key",
]
missing_configs = [c for c in required_configs if not db_configs.get(c)]
missing_configs = [key for key in required_configs if not db_configs.get(key)]
if missing_configs:
raise ValueError(f"微信支付未完成配置,缺少: {', '.join(missing_configs)},请联系管理员")
total_credits = credits + bonus_credits
checked_at = request_time or utc_now()
order_id = generate_id()
order_no = generate_order_no()
product: CreditProduct | None = None
quote = None
# 先持久化订单主记录,再做升级周期预留。订阅周期的 upgrade_order_id
# 有外键约束,若先更新周期后插入订单,flush 顺序可能触发外键异常。
order = PaymentOrder(
id=generate_id(),
id=order_id,
user_id=user_id,
order_no=generate_order_no(),
order_no=order_no,
amount=price,
credits=total_credits,
credits=round(float(credits or 0) + float(bonus_credits or 0), 2),
payment_method=method,
status="pending",
purchase_scene="legacy_recharge",
price_type="regular",
product_name_snapshot=label,
target_price_snapshot=price,
deduction_amount_snapshot=0,
payable_amount_snapshot=price,
)
db.add(order)
await db.flush()
if product_id:
product_result = await db.execute(
select(CreditProduct).where(CreditProduct.id == product_id, CreditProduct.is_active.is_(True)).limit(1)
)
product = product_result.scalar_one_or_none()
if product is None:
raise ValueError("积分商品不存在或已下架")
# 订阅报价服务内部先获取用户级 advisory lock,再按统一顺序锁订阅周期。
# 此处不预先锁 users 行,避免与支付履约(advisory -> users)形成反向锁序。
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = user_result.scalar_one_or_none()
if user is None:
raise ValueError("用户不存在")
quote = await quote_and_reserve_product_purchase(
db, user=user, product=product, order_id=order.id, request_time=checked_at
)
price = float(quote.payable_amount)
label = product.name
credits = float(product.grant_credits or product.monthly_grant_credits or 0)
bonus_credits = 0.0
order.amount = quote.payable_amount
order.credits = round(float(credits or 0), 2)
order.product_id = product.id
order.product_type = product.product_type
order.purchase_scene = quote.purchase_scene
order.price_type = quote.price_type
order.product_code_snapshot = product.product_code
order.product_name_snapshot = product.name
product_snapshot = product_to_dict(product)
for time_key in ("activity_start_at", "activity_end_at"):
value = product_snapshot.get(time_key)
if value is not None:
product_snapshot[time_key] = value.isoformat()
order.product_snapshot_json = product_snapshot
order.source_subscription_id = quote.source_subscription_id
order.upgrade_period_ids_json = list(quote.upgrade_period_ids) or None
order.target_price_snapshot = quote.target_price
order.deduction_amount_snapshot = quote.deduction_amount
order.payable_amount_snapshot = quote.payable_amount
order.fulfillment_status = "pending"
total_credits = round(float(credits or 0) + float(bonus_credits or 0), 2)
await db.flush()
logger.info(
f"ORDER_CREATED order_no={order.order_no} user={user_id} "
f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
f"ORDER_CREATED order_no={order.order_no} user={user_id} amount={price} "
f"credits={total_credits} method={method} product_id={product_id} mock={mock_mode}"
)
if mock_mode:
# Mock: immediately complete payment
order.status = "paid"
order.paid_at = datetime.now()
order.paid_at = checked_at
desc = f"充值{label}({total_credits}积分)"
if bonus_credits > 0:
desc += f"(含赠送{bonus_credits}积分)"
await add_credits(
await _fulfill_paid_order(
db,
user_id,
total_credits,
desc,
related_id=order.id,
biz_key=_payment_biz_key(
order,
charge_kind=CreditRecordChargeKind.RECHARGE.value,
action=CreditRecordAction.CHARGE.value,
),
record_meta=_payment_recharge_meta(order),
order=order,
fulfilled_at=checked_at,
legacy_description=desc,
)
await db.flush()
else:
# Real payment: delegate to WeChat or Alipay
if method == "wechat":
qr_code_content = _create_wechat_order(order, db_configs)
if qr_code_content:
# Attach QR code content to the order instance (transient, not persisted)
order.qr_url = qr_code_content # type: ignore[attr-defined]
else:
# Precreate failed — do not leave a pending order that can never be paid
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("微信支付预下单失败,请检查配置或稍后重试")
elif method == "alipay":
qr_url = _create_alipay_order(order, db_configs)
if qr_url:
# Attach QR URL to the order instance (transient, not persisted)
order.qr_url = qr_url # type: ignore[attr-defined]
else:
# Precreate failed — do not leave a pending order that can never be paid
if quote and quote.upgrade_period_ids:
await release_upgrade_reservation(db, order=order, released_at=checked_at)
raise ValueError("支付宝预下单失败,请检查配置或稍后重试")
return order
@@ -523,10 +687,12 @@ def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
return None
async def _close_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool:
"""Call WeChat Pay close API to close an unpaid order.
Returns True if the order was closed successfully.
"""
async def _close_wechat_order(
db: AsyncSession,
order: PaymentOrder,
db_configs: dict[str, str],
) -> PaymentCloseResult:
"""Call WeChat Pay close API and classify the authoritative result."""
mch_id = db_configs.get("payment_wechat_mch_id", "")
private_key = db_configs.get("payment_wechat_private_key", "")
cert_serial_no = db_configs.get("payment_wechat_cert_serial_no", "")
@@ -538,24 +704,58 @@ async def _close_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs:
client = _get_wechat_client(mch_id, private_key, cert_serial_no, api_v3_key, appid, notify_url, public_key, public_key_id)
if client is None:
return False
return PaymentCloseResult.FAILED
mock_mode = _is_mock_mode(db_configs)
if mock_mode:
logger.info(f"Mock mode: skipping close_wechat_order for {order.order_no}")
return True
return PaymentCloseResult.CLOSED
try:
code, result = client.close(out_trade_no=order.order_no)
if code == 204:
logger.info(f"WeChat order closed: order_no={order.order_no}")
return True
else:
logger.error(f"WeChat close failed: order_no={order.order_no}, code={code}, result={result}")
return False
return PaymentCloseResult.CLOSED
data = _parse_wechat_result(result)
error_code = str(
data.get("code")
or data.get("err_code")
or data.get("error_code")
or ""
).upper()
raw_result = str(result).upper()
# 微信明确返回“订单不存在”时,同样视为没有远端订单需要继续关闭。
# 只兼容明确的订单不存在错误,不吞掉签名、网络、系统等其他异常。
if error_code in {"ORDER_NOT_EXIST", "ORDERNOTEXIST"} or (
code == 404
and ("ORDER_NOT_EXIST" in raw_result or "ORDERNOTEXIST" in raw_result)
):
logger.info(
f"WeChat order not exist, treat as closed for local expiry: "
f"order_no={order.order_no}, code={code}, error_code={error_code or 'unknown'}"
)
return PaymentCloseResult.CLOSED
# ORDERPAID means the remote transaction is already paid. Do not
# cancel locally and do not retry close forever; the caller will query
# this one order once and recover it through the normal paid pipeline.
if error_code == "ORDERPAID" or "ORDERPAID" in raw_result:
logger.info(
f"WeChat close reports order already paid: "
f"order_no={order.order_no}, code={code}"
)
return PaymentCloseResult.PAID
logger.error(
f"WeChat close failed: order_no={order.order_no}, "
f"code={code}, result={result}"
)
return PaymentCloseResult.FAILED
except Exception as e:
logger.exception(f"WeChat close exception: order_no={order.order_no}")
return False
return PaymentCloseResult.FAILED
async def _query_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None:
@@ -600,6 +800,95 @@ async def _query_wechat_order(db: AsyncSession, order: PaymentOrder, db_configs:
return None
async def _recover_wechat_paid_order(
db: AsyncSession,
order: PaymentOrder,
db_configs: dict[str, str],
) -> bool:
"""Recover one WeChat order after close reports ``ORDERPAID``.
This is not a periodic query of all pending orders. It runs only after
WeChat itself explicitly says this specific order has already been paid.
The query supplies the authoritative transaction id and amount, then the
existing atomic payment-success handler performs all subscription / upgrade
/ credit fulfillment.
"""
local_order_no = str(order.order_no)
data = await _query_wechat_order(db, order, db_configs)
if not data:
logger.warning(
f"WECHAT_ORDERPAID_QUERY_FAILED order_no={local_order_no}"
)
return False
trade_state = str(data.get("trade_state") or "").upper()
remote_order_no = str(data.get("out_trade_no") or "")
transaction_id = str(data.get("transaction_id") or "")
amount_info = data.get("amount") or {}
currency = str(amount_info.get("currency") or "CNY").upper()
total_fen = amount_info.get("total")
if trade_state != "SUCCESS":
logger.warning(
f"WECHAT_ORDERPAID_QUERY_NOT_SUCCESS order_no={local_order_no} "
f"trade_state={trade_state or 'unknown'}"
)
return False
if remote_order_no and remote_order_no != local_order_no:
logger.error(
f"WECHAT_ORDERPAID_ORDER_MISMATCH local={local_order_no} "
f"remote={remote_order_no}"
)
return False
if currency != "CNY":
logger.error(
f"WECHAT_ORDERPAID_CURRENCY_MISMATCH order_no={local_order_no} "
f"currency={currency}"
)
return False
if total_fen is None:
logger.error(
f"WECHAT_ORDERPAID_AMOUNT_MISSING order_no={local_order_no}"
)
return False
try:
total_amount = float(to_credit_decimal(total_fen) / to_credit_decimal(100))
except Exception:
logger.exception(
f"WECHAT_ORDERPAID_AMOUNT_INVALID order_no={local_order_no} "
f"total={total_fen}"
)
return False
if not transaction_id:
logger.error(
f"WECHAT_ORDERPAID_TRANSACTION_ID_MISSING order_no={local_order_no}"
)
return False
processed = await process_payment_success_by_order_no(
db,
local_order_no,
transaction_id,
total_amount,
)
if processed:
logger.info(
f"WECHAT_ORDERPAID_RECOVERED order_no={local_order_no} "
f"transaction_id={transaction_id} amount={total_amount}"
)
else:
logger.warning(
f"WECHAT_ORDERPAID_RECOVERY_NOT_PROCESSED order_no={local_order_no} "
f"transaction_id={transaction_id} amount={total_amount}"
)
return processed
async def _refund_wechat_order(
db: AsyncSession,
order: PaymentOrder,
@@ -810,7 +1099,18 @@ async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs:
if response.is_success():
logger.info(f"Alipay order closed: order_no={order.order_no}")
return True
else:
# 支付宝明确返回“交易不存在”时,远端没有可继续关闭的交易。
# 对本地已经达到超时时间的 pending 订单,可视为关单目标已经满足,
# 避免 Celery 每分钟对同一订单无限重复调用 trade.close。
if str(response.sub_code or "").upper() == "ACQ.TRADE_NOT_EXIST":
logger.info(
f"Alipay trade not exist, treat as closed for local expiry: "
f"order_no={order.order_no}, code={response.code}, "
f"sub_code={response.sub_code}"
)
return True
logger.error(
f"Alipay close failed: code={response.code}, "
f"msg={response.msg}, sub_code={response.sub_code}, "
@@ -897,56 +1197,78 @@ async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs:
async def sync_pending_orders(db: AsyncSession) -> int:
"""Check pending orders via Alipay/WeChat query and update status.
Returns the number of orders updated.
Candidate rows are collected as primitive values first. A failed payment
fulfillment may roll back the session; using primitive candidates prevents
that rollback from expiring ORM objects needed by later iterations.
"""
result = await db.execute(
select(PaymentOrder).where(
select(PaymentOrder.order_no, PaymentOrder.payment_method).where(
PaymentOrder.status == "pending",
)
)
orders = result.scalars().all()
candidates = [(str(row.order_no), str(row.payment_method)) for row in result.all()]
updated_count = 0
db_configs = await _get_payment_configs(db)
for order in orders:
for order_no, payment_method in candidates:
try:
if order.payment_method == "alipay":
order_result = await db.execute(
select(PaymentOrder).where(
PaymentOrder.order_no == order_no,
PaymentOrder.status == "pending",
).limit(1)
)
order = order_result.scalar_one_or_none()
if order is None:
continue
if payment_method == "alipay":
data = await _query_alipay_order(db, order, db_configs)
if data:
trade_status = data.get("trade_status")
if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
# Order was paid but we missed the callback
trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None
await process_payment_success_by_order_no(db, order.order_no, trade_no, total_amount)
processed = await process_payment_success_by_order_no(
db, order_no, trade_no, total_amount
)
if processed:
updated_count += 1
elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
# Order was closed on Alipay side
order.status = "cancelled"
await db.flush()
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
elif order.payment_method == "wechat":
elif payment_method == "wechat":
data = await _query_wechat_order(db, order, db_configs)
if data:
trade_state = data.get("trade_state")
if trade_state == "SUCCESS":
# Order was paid but we missed the callback
transaction_id = data.get("transaction_id", "")
total_amount = float(data.get("amount", {}).get("total", 0)) / 100
await process_payment_success_by_order_no(db, order.order_no, transaction_id, total_amount)
processed = await process_payment_success_by_order_no(
db, order_no, transaction_id, total_amount
)
if processed:
updated_count += 1
elif trade_state in ("CLOSED", "REVOKED"):
# Order was closed on WeChat side
order.status = "cancelled"
await db.flush()
if order.upgrade_period_ids_json:
await release_upgrade_reservation(
db, order=order, released_at=utc_now()
)
await db.commit()
updated_count += 1
except Exception as e:
logger.exception(f"Failed to sync order {order.order_no}: {e}")
await db.rollback()
logger.exception(f"Failed to sync order {order_no}: {e}")
if updated_count > 0:
await db.flush()
return updated_count
@@ -1119,22 +1441,34 @@ async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool:
# ---------------------------------------------------------------------------
async def process_payment_success(db: AsyncSession, order_id: str):
"""Process successful payment: update order and add credits."""
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.id == order_id).with_for_update().limit(1)
async def _fulfill_paid_order(
db: AsyncSession,
*,
order: PaymentOrder,
fulfilled_at: datetime,
legacy_description: str,
) -> None:
"""Fulfill the business side of a paid order without committing.
Product orders are considered successfully paid locally only when the
product service leaves the order in the explicit ``fulfilled`` state.
This turns any internal upgrade/reconciliation failure into an exception so
the outer payment transaction can roll back the temporary ``paid`` state.
"""
if order.product_id:
await fulfill_payment_product(db, order=order, fulfilled_at=fulfilled_at)
if order.fulfillment_status != "fulfilled":
raise RuntimeError(
f"Payment product fulfillment incomplete: order_no={order.order_no}, "
f"fulfillment_status={order.fulfillment_status}"
)
order = result.scalar_one_or_none()
if not order or order.status != "pending":
return
order.status = "paid"
order.paid_at = datetime.now()
await add_credits(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分)",
legacy_description,
related_id=order.id,
biz_key=_payment_biz_key(
order,
@@ -1142,76 +1476,120 @@ async def process_payment_success(db: AsyncSession, order_id: str):
action=CreditRecordAction.CHARGE.value,
),
record_meta=_payment_recharge_meta(order),
payment_order_id=order.id,
source_id=order.id,
)
async def process_payment_success(db: AsyncSession, order_id: str) -> bool:
"""Atomically mark a pending order paid and fulfill its business value."""
try:
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.id == order_id)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if not order:
return False
if order.status == "paid":
return True
if order.status != "pending":
return False
paid_at = utc_now()
order.status = "paid"
order.paid_at = paid_at
await _fulfill_paid_order(
db,
order=order,
fulfilled_at=paid_at,
legacy_description=f"充值成功({order.credits}积分)",
)
await db.commit()
return True
except Exception:
await db.rollback()
raise
async def process_payment_success_by_order_no(
db: AsyncSession,
order_no: str,
trade_no: str = "",
total_amount: float | None = None
):
"""Process successful payment by order_no (used by Alipay/WeChat callbacks).
total_amount: float | None = None,
) -> bool:
"""Atomically process an Alipay/WeChat payment success.
Args:
db: async database session
order_no: the merchant order number (out_trade_no)
trade_no: the Alipay trade number (trade_no), optional
total_amount: the payment amount from the gateway, for consistency check
``paid`` is persisted only together with successful local fulfillment.
When fulfillment fails, the whole local transaction is rolled back and the
order remains ``pending``; the existing gateway query loop may retry it.
"""
try:
result = await db.execute(
select(PaymentOrder).where(PaymentOrder.order_no == order_no).with_for_update().limit(1)
select(PaymentOrder)
.where(PaymentOrder.order_no == order_no)
.with_for_update()
.limit(1)
)
order = result.scalar_one_or_none()
if not order:
logger.info(f"Order {order_no} not found, skipping")
return
return False
if order.status == "paid":
logger.info(f"Order {order_no} already processed, skipping")
return
return True
if order.status != "pending":
logger.info(f"Order {order_no} is in {order.status} state, cannot process")
return
return False
# 金额一致性校验
if total_amount is not None and abs(total_amount - order.amount) > 0.01:
if (
total_amount is not None
and abs(
to_credit_decimal(total_amount) - to_credit_decimal(order.amount)
) > to_credit_decimal("0.01")
):
logger.error(
f"Amount mismatch: order amount {order.amount}, gateway amount {total_amount}"
)
return
# 幂等性检查:如果trade_no已存在且相同,则跳过
if trade_no and order.trade_no and order.trade_no == trade_no:
logger.info(f"Trade no {trade_no} already processed, skipping")
return
return False
# A pending order must never be skipped merely because trade_no is
# already present. Historical partial writes can otherwise remain
# pending forever. The row lock + paid state + credit biz_key provide
# the actual idempotency boundary.
paid_at = utc_now()
order.status = "paid"
order.paid_at = datetime.now()
order.paid_at = paid_at
if trade_no:
order.trade_no = trade_no
await add_credits(
await _fulfill_paid_order(
db,
order.user_id,
order.credits,
f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}",
related_id=order.id,
biz_key=_payment_biz_key(
order,
charge_kind=CreditRecordChargeKind.RECHARGE.value,
action=CreditRecordAction.CHARGE.value,
order=order,
fulfilled_at=paid_at,
legacy_description=(
f"充值成功({order.credits}积分), "
f"订单号: {order_no}, 金额: {order.amount}"
),
record_meta=_payment_recharge_meta(order),
)
log_user_id = str(order.user_id)
log_amount = order.amount
log_credits = order.credits
await db.commit()
logger.info(
f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
f"PAYMENT_SUCCESS order_no={order_no} user={log_user_id} "
f"amount={log_amount} credits={log_credits} trade_no={trade_no}"
)
return True
except Exception:
await db.rollback()
raise
async def process_refund(
@@ -1245,10 +1623,10 @@ async def process_refund(
if order.refunded_at is not None:
return {"success": False, "message": "订单已退款"}
refund_amount = refund_amount or order.amount
refund_amount = to_credit_decimal(refund_amount if refund_amount is not None else order.amount)
# 金额校验
if refund_amount > order.amount:
if refund_amount > to_credit_decimal(order.amount):
return {"success": False, "message": "退款金额超过订单金额"}
# 根据支付方式调用相应的退款API
@@ -1266,24 +1644,19 @@ async def process_refund(
if not refund_result.get("success"):
return refund_result
# 扣除积分
# 按原支付业务位置适配新积分账本;不改变支付渠道退款流程。
try:
if order.product_id:
await revoke_payment_order_credits(db, order=order, reason=refund_reason)
else:
await deduct_credits(
db,
order.user_id,
order.credits,
refund_reason,
related_id=order.id,
biz_key=_payment_biz_key(
order,
charge_kind=CreditRecordChargeKind.REFUND.value,
action=CreditRecordAction.REFUND.value,
),
refund_for_biz_key=_payment_biz_key(
order,
charge_kind=CreditRecordChargeKind.RECHARGE.value,
action=CreditRecordAction.CHARGE.value,
),
biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.REFUND.value, action=CreditRecordAction.REFUND.value),
refund_for_biz_key=_payment_biz_key(order, charge_kind=CreditRecordChargeKind.RECHARGE.value, action=CreditRecordAction.CHARGE.value),
record_meta=_payment_refund_meta(order),
)
except Exception as e:
@@ -1293,7 +1666,7 @@ async def process_refund(
# 更新订单状态
order.status = "refunded"
order.refund_amount = refund_amount
order.refunded_at = datetime.now()
order.refunded_at = utc_now()
if order.payment_method == "alipay":
order.refund_trade_no = db_configs.get("refund_trade_no", "")
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType
from app.enums.llm_billing import LlmBillingConfigKey
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -59,13 +58,15 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt
from app.services.llm_billing import (
LlmBillingContext,
ensure_hold_exists,
ensure_llm_charged,
log_provider_failure,
record_provider_exception,
log_provider_start,
log_provider_success,
release_on_failure,
settle_success,
start_hold,
refund_on_final_failure,
finalize_llm_business_failure,
mark_business_success,
charge_llm_credits,
)
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
@@ -841,7 +842,7 @@ async def submit_image_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -855,9 +856,8 @@ async def submit_image_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_SUBMITTED.value, message="图片 AI 提词任务已提交")
@@ -954,14 +954,13 @@ async def run_image_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value,
description_prefix="拆镜复刻图片AI提词优化",
trace_id=f"shot-image-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -972,7 +971,7 @@ async def run_image_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "image"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "image"})
try:
request_log = {"original_prompt": prompt_text, "references": references, "gen_type": "image"}
log_module_prompt_event(
@@ -997,9 +996,11 @@ async def run_image_prompt_optimize(
log_owner_type="module_generation_step",
log_owner_id=step_id_value,
generation_attempt_no=expected_step_version,
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1013,15 +1014,10 @@ async def run_image_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1069,21 +1065,19 @@ async def run_image_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1098,16 +1092,7 @@ async def run_image_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1126,16 +1111,8 @@ async def run_image_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="IMAGE_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.IMAGE_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-图片AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1309,7 +1286,7 @@ async def submit_video_prompt_optimize(
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value
project.error_message = None
await start_hold(
await charge_llm_credits(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1323,9 +1300,8 @@ async def submit_video_prompt_optimize(
source_step_id=str(step.id),
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=str(step.id),
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"llm-submit-hold:{step.id}",
trace_id=f"llm-submit-pre-deduct:{step.id}",
),
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUBMITTED.value, message="视频 AI 提词任务已提交")
@@ -1420,14 +1396,13 @@ async def run_video_prompt_optimize(
source_step_id=step_id_value,
source_step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
related_id=step_id_value,
hold_config_key=LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value,
description_prefix="拆镜复刻视频AI提词优化",
trace_id=f"shot-video-prompt:{step_id_value}",
)
hold_validation = await ensure_hold_exists(db, llm_billing_context)
if not hold_validation.can_execute:
charge_validation = await ensure_llm_charged(db, llm_billing_context)
if not charge_validation.can_execute:
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = f"LLM账务状态异常({hold_validation.state.value}),已终止任务"
step.error_message = f"LLM账务状态异常({charge_validation.state.value}),已终止任务"
step.completed_at = _now()
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = step.error_message
@@ -1438,7 +1413,7 @@ async def run_video_prompt_optimize(
provider_succeeded = False
token_usage: dict[str, Any] = {}
log_provider_start(llm_billing_context, detail={"prompt_type": "video"})
await log_provider_start(db, llm_billing_context, detail={"prompt_type": "video"})
try:
request_log = {
"source_project_name": material.get("source_project_name") or "",
@@ -1475,9 +1450,11 @@ async def run_video_prompt_optimize(
project_id=project_id_value,
step_id=step_id_value,
trace_id=f"shot-video-prompt:{step_id_value}",
fixed_model_config_id=llm_billing_context.model_config_id,
fixed_model_snapshot=llm_billing_context.model_parameters_snapshot,
)
provider_succeeded = True
log_provider_success(llm_billing_context, usage=token_usage)
await log_provider_success(db, llm_billing_context, usage=token_usage)
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1491,15 +1468,10 @@ async def run_video_prompt_optimize(
expected_version=expected_step_version,
expected_input_json=expected_input_json,
):
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(失效结果结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="当前步骤已失效,业务结果未采用")
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务结果未采用")
return None
billing = await settle_success(
billing = await mark_business_success(
db,
llm_billing_context,
usage=token_usage,
@@ -1550,21 +1522,19 @@ async def run_video_prompt_optimize(
except DatabaseRowLockBusy:
await db.rollback()
if provider_succeeded:
# Provider 已完成后不再重复调用模型;先按真实 usage 结算,本次结果因本地行锁冲突丢弃。
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(行锁失败结算)",
)
await db.commit()
await log_provider_failure(db, llm_billing_context, error="本地行锁冲突,业务结果未落库")
await finalize_llm_business_failure(llm_billing_context, error="本地行锁冲突,业务结果未落库")
return None
# Provider 尚未成功才允许同一 attempt 做系统自动重试。
raise
except Exception as exc:
await db.rollback()
if not provider_succeeded:
log_provider_failure(llm_billing_context, error=str(exc))
if provider_succeeded:
await log_provider_failure(db, llm_billing_context, error=str(exc))
else:
provider_succeeded, recovered_usage = await record_provider_exception(db, llm_billing_context, exc)
if recovered_usage:
token_usage = recovered_usage
if execution_guard is not None:
await execution_guard()
project, step = await _reload_prompt_context_for_update(
@@ -1579,16 +1549,7 @@ async def run_video_prompt_optimize(
expected_input_json=expected_input_json,
):
await db.rollback()
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(异常失效结算)",
)
else:
await release_on_failure(db, llm_billing_context, error="当前步骤已失效,释放LLM预扣积分")
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error="当前步骤已失效,业务最终失败")
return None
step.status = ModuleStepStatusEnum.FAILED.value
step.error_message = str(exc) if str(exc) else type(exc).__name__
@@ -1607,16 +1568,8 @@ async def run_video_prompt_optimize(
)
_log_project_error(project=project, step=step, event_type="VIDEO_PROMPT_FAILED", message=project.error_message, exc=exc)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_FAILED.value, message=project.error_message)
if provider_succeeded:
await settle_success(
db,
llm_billing_context,
usage=token_usage,
description="拆镜复刻-视频AI提词优化(本地失败结算)",
)
else:
await release_on_failure(db, llm_billing_context, error=str(exc))
await db.commit()
await finalize_llm_business_failure(llm_billing_context, error=str(exc))
return step
@@ -1937,7 +1890,7 @@ async def mark_shot_replicate_step_dispatch_failed(
project.status = ModuleProjectStatusEnum.FAILED.value
project.error_message = error_message
if step.step_code in (ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value, ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value):
await release_on_failure(
await refund_on_final_failure(
db,
LlmBillingContext(
user_id=str(project.user_id),
@@ -1955,11 +1908,6 @@ async def mark_shot_replicate_step_dispatch_failed(
source_step_id=str(step.id),
source_step_code=str(step.step_code),
related_id=str(step.id),
hold_config_key=(
LlmBillingConfigKey.HOLD_MODULE_IMAGE_PROMPT.value
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
else LlmBillingConfigKey.HOLD_MODULE_VIDEO_PROMPT.value
),
description_prefix=(
"拆镜复刻图片AI提词优化"
if step.step_code == ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.enums.llm_billing import LlmBillingConfigKey, LlmBillingLedgerState
from app.enums.llm_billing import LlmBillingLedgerState
from app.enums.shot_replicate import ShotSplitStatusEnum
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.shot_replicate_task_set import ShotReplicateTaskSet
@@ -19,7 +19,6 @@ from app.services.shot_replicate_taskset_service import (
refresh_task_set_split_summaries,
)
from app.services.llm_billing import get_llm_ledger_states
from app.services.llm_billing.config import get_llm_billing_policy
from app.services.celery_runtime.runtime_service import runtime_lock_values
from app.tasks.celery_app import celery_app
@@ -230,15 +229,11 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
)
segments = list(segment_result.scalars().all())
billing_policy = await get_llm_billing_policy(
db,
config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
)
billing_contexts = [
*(build_task_set_analysis_billing_context(item) for item in task_sets),
*(build_segment_analysis_billing_context(item) for item in segments),
]
# 配置关闭后仍需识别并继续处理已存在的 active HOLD;只有 missing 流水才按 bypass
# 场景积分消费配置变更后仍需识别并继续处理已存在的有效消费;缺少消费记录时禁止恢复
billing_states = await get_llm_ledger_states(db, billing_contexts)
lock_keys: list[str] = []
@@ -264,13 +259,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_task_set_lock"] = results.get("skip_live_task_set_lock", 0) + 1
continue
context = build_task_set_analysis_billing_context(item)
validation = billing_states.get(context.hold_biz_key)
validation = billing_states.get(context.charge_biz_key)
can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed:
can_execute = True
state = LlmBillingLedgerState.BILLING_BYPASSED.value
elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotAnalysisStatusEnum.FAILED.value
@@ -297,13 +289,10 @@ async def recover_shot_analysis_tasks_once(db: AsyncSession) -> dict[str, Any]:
results["skip_live_segment_lock"] = results.get("skip_live_segment_lock", 0) + 1
continue
context = build_segment_analysis_billing_context(item)
validation = billing_states.get(context.hold_biz_key)
validation = billing_states.get(context.charge_biz_key)
can_execute = bool(validation and validation.can_execute)
state = validation.state.value if validation else LlmBillingLedgerState.MISSING.value
if validation and validation.state == LlmBillingLedgerState.MISSING and billing_policy.bypassed:
can_execute = True
state = LlmBillingLedgerState.BILLING_BYPASSED.value
elif validation and validation.state == LlmBillingLedgerState.MISSING and not billing_policy.valid:
if validation and validation.state == LlmBillingLedgerState.MISSING:
state = LlmBillingLedgerState.INVALID.value
if not can_execute:
item.analysis_status = ShotSegmentAnalysisStatusEnum.FAILED.value
@@ -16,7 +16,6 @@ from app.enums.credit_record import (
CreditRecordSourceModule,
CreditRecordSourceStepCode,
)
from app.enums.llm_billing import LlmBillingConfigKey
from sqlalchemy import String, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -56,8 +55,8 @@ from app.schemas.shot_replicate import (
from app.services.module_generation_log_service import log_module_event_file
from app.services.llm_billing import (
LlmBillingContext,
release_on_failure,
start_hold,
refund_on_final_failure,
charge_llm_credits,
validate_retryable_previous_attempt,
)
from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source
@@ -94,7 +93,6 @@ def build_task_set_analysis_billing_context(task_set: ShotReplicateTaskSet) -> L
source_step_id=str(task_set.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(task_set.id),
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix="拆镜复刻原视频AI分析",
trace_id=f"shot-task-set-analysis:{task_set.id}:attempt:{int(task_set.analysis_attempt_no or 1)}",
)
@@ -113,7 +111,6 @@ def build_segment_analysis_billing_context(segment: ShotReplicateSegment) -> Llm
source_step_id=str(segment.id),
source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value,
related_id=str(segment.id),
hold_config_key=LlmBillingConfigKey.HOLD_SHOT_VIDEO_ANALYSIS.value,
description_prefix="拆镜复刻片段视频AI分析",
trace_id=f"shot-segment-analysis:{segment.id}:attempt:{int(segment.analysis_attempt_no or 1)}",
)
@@ -247,7 +244,7 @@ async def create_task_set(
)
existing = existing_result.scalar_one_or_none()
if existing:
# 幂等命中只返回已有任务,不重复预扣、绑定资源或投递 Celery。
# 幂等命中只返回已有任务,不重复消费积分、绑定资源或投递 Celery。
return existing, False
asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds)
@@ -268,7 +265,7 @@ async def create_task_set(
)
db.add(task_set)
await db.flush()
await start_hold(db, build_task_set_analysis_billing_context(task_set))
await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set))
log_module_event_file(
module=MODULE,
event_type="SHOT_TASK_SET_CREATED",
@@ -686,7 +683,7 @@ async def prepare_retry_split_segment(
await refresh_task_set_split_summary(db, task_set.id)
await db.flush()
# 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复预扣
# 切片重试只重放本地视频切割,不创建新的 LLM attempt,也不重复消费积分
# 切片成功后仍会继续原 attempt 的片段分析;显式重新分析才走 reanalyze_segment。
log_module_event_file(
module=MODULE,
@@ -751,7 +748,7 @@ async def create_custom_segment(
task_set.status = ShotTaskSetStatusEnum.SPLITTING.value
task_set.split_status = ShotSplitStatusEnum.PROCESSING.value
await db.flush()
await start_hold(db, build_segment_analysis_billing_context(segment))
await charge_llm_credits(db, build_segment_analysis_billing_context(segment))
await refresh_task_set_split_summary(db, task_set.id)
await db.flush()
log_module_event_file(
@@ -920,10 +917,10 @@ async def delete_segment(
raise HTTPException(status_code=409, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除")
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
await release_on_failure(
await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除自定义拆镜片段,释放未结算的片段分析预扣",
error="用户删除自定义拆镜片段,退回片段分析消费积分",
)
deleted_at = _now()
@@ -982,7 +979,7 @@ async def delete_segment(
"pending_delete_resource_count": len(pending_delete_resource_ids),
"physical_file_delete": "after_commit",
"media_refund": False,
"llm_hold_release_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
"llm_charge_refund_on_cancel": segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value,
},
)
@@ -1037,17 +1034,17 @@ async def delete_task_set(
# 删除是对未执行/失败任务的最终取消动作;处理中的任务已在上方拦截。
# 这里仅做账务补偿,不改变现有逐项目删除流程。
await release_on_failure(
await refund_on_final_failure(
db,
build_task_set_analysis_billing_context(task_set),
error="用户删除拆镜任务集,释放未结算的原视频分析预扣",
error="用户删除拆镜任务集,退回原视频分析消费积分",
)
for segment in segments:
if segment.source_mode == ShotSegmentSourceModeEnum.CUSTOM.value:
await release_on_failure(
await refund_on_final_failure(
db,
build_segment_analysis_billing_context(segment),
error="用户删除拆镜任务集,释放未结算的片段分析预扣",
error="用户删除拆镜任务集,退回片段分析消费积分",
)
segment_ids = [segment.id for segment in segments]
@@ -1113,7 +1110,7 @@ async def delete_task_set(
"segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"},
"physical_file_delete": "after_commit",
"media_refund": False,
"llm_hold_release_on_cancel": True,
"llm_charge_refund_on_cancel": True,
},
)
return ShotTaskSetDeleteOut(
@@ -1198,7 +1195,7 @@ async def prepare_reanalyze_task_set(
task_set.analysis_raw_json = None
task_set.analysis_result_json = None
await db.flush()
await start_hold(db, build_task_set_analysis_billing_context(task_set))
await charge_llm_credits(db, build_task_set_analysis_billing_context(task_set))
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value,
@@ -1304,7 +1301,7 @@ async def prepare_reanalyze_segment(
segment.segment_category = None
segment.segment_audience = None
await db.flush()
await start_hold(db, build_segment_analysis_billing_context(segment))
await charge_llm_credits(db, build_segment_analysis_billing_context(segment))
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value,
@@ -1357,7 +1354,7 @@ async def mark_task_set_analysis_dispatch_failed(
task_set.analysis_claim_token = None
task_set.analysis_lease_until = None
task_set.analysis_error_message = error_message
await release_on_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
await refund_on_final_failure(db, build_task_set_analysis_billing_context(task_set), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1394,7 +1391,7 @@ async def mark_segment_analysis_dispatch_failed(
segment.analysis_claim_token = None
segment.analysis_lease_until = None
segment.analysis_error_message = error_message
await release_on_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
await refund_on_final_failure(db, build_segment_analysis_billing_context(segment), error=error_message)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -1423,7 +1420,7 @@ async def mark_custom_segment_split_dispatch_failed(
segment.split_next_retry_at = None
segment.split_last_error = error_message
# 切片投递失败不改变 LLM attempt 的冻结状态。用户重试切片时继续沿用
# 原 active HOLD;只有片段分析最终失败或用户删除片段时才释放
# 原场景积分消费;只有片段分析最终失败或用户删除片段时才按原来源退款
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
@@ -17,6 +17,7 @@ from app.services.resource_signed_url_service import build_resource_signed_url
from app.enums.common import LogEventStatusEnum, LogSourceEnum
from app.enums.shot_replicate import ModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
from app.services.operation_log_service import log_ai_model_event
from app.services.llm_billing.context import LlmProviderPostprocessError
from app.utils.id_gen import generate_id
AnalysisMode = Literal["full_breakdown", "summary_only"]
@@ -411,13 +412,15 @@ def filter_and_normalize_breakdown(result: dict[str, Any], *, mode: AnalysisMode
return result
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
result = await db.execute(
select(ModelConfig)
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
.order_by(ModelConfig.priority.desc())
.limit(1)
async def _select_model_config(db: AsyncSession, model_config_id: str | None = None) -> ModelConfig | None:
stmt = select(ModelConfig).where(
ModelConfig.is_active == True,
ModelConfig.deleted_at.is_(None),
ModelConfig.provider != "mock",
)
if model_config_id:
stmt = stmt.where(ModelConfig.id == model_config_id)
result = await db.execute(stmt.order_by(ModelConfig.priority.desc(), ModelConfig.id.asc()).limit(1))
return result.scalar_one_or_none()
@@ -559,6 +562,8 @@ async def analyze_video_for_shot_split(
task_set_id: str | None = None,
segment_id: str | None = None,
trace_id: str | None = None,
fixed_model_config_id: str | None = None,
fixed_model_snapshot: dict[str, Any] | None = None,
) -> ShotVideoAnalysisResult:
"""调用模型完成拆镜/片段分析。
@@ -568,19 +573,20 @@ async def analyze_video_for_shot_split(
"""
trace_id = trace_id or generate_id()
call_id = generate_id()
config_row = await _select_model_config(db)
config_row = await _select_model_config(db, fixed_model_config_id)
if not config_row:
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
# 外部调用前转换为纯数据快照,随后释放数据库事务,避免一小时 HTTP 请求期间 idle in transaction。
snapshot = dict(fixed_model_snapshot or {})
config = SimpleNamespace(
id=str(config_row.id),
name=str(config_row.name or ""),
provider=str(config_row.provider or ""),
api_base=str(config_row.api_base or ""),
name=str(snapshot.get("name") or config_row.name or ""),
provider=str(snapshot.get("provider") or config_row.provider or ""),
api_base=str(snapshot.get("api_base") or config_row.api_base or ""),
api_key=str(config_row.api_key or ""),
model_name=str(config_row.model_name or ""),
max_tokens=getattr(config_row, "max_tokens", None),
temperature=getattr(config_row, "temperature", None),
model_name=str(snapshot.get("model_name") or config_row.model_name or ""),
max_tokens=snapshot.get("max_tokens") if snapshot.get("max_tokens") is not None else getattr(config_row, "max_tokens", None),
temperature=snapshot.get("temperature") if snapshot.get("temperature") is not None else getattr(config_row, "temperature", None),
)
if not str(config.api_key or "").strip():
raise RuntimeError(f"拆镜分析模型 API Key 为空: model_config_id={config.id}")
@@ -610,8 +616,8 @@ async def analyze_video_for_shot_split(
{"role": "system", "content": system_prompt},
user_message,
],
"max_tokens": int(getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or getattr(config, "max_tokens", 5000) or 5000),
"temperature": float(getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or getattr(config, "temperature", 0.1) or 0.1),
"max_tokens": int((getattr(settings, "SHOT_ANALYSIS_MAX_TOKENS", 5000) or 5000)),
"temperature": float((getattr(settings, "SHOT_ANALYSIS_TEMPERATURE", 0.1) or 0.1)),
"response_format": {"type": "json_object"},
}
log_request_data: dict[str, Any] = {
@@ -729,33 +735,6 @@ async def analyze_video_for_shot_split(
)
raise
try:
content = get_message_content_or_raise(raw)
result = parse_model_json(content)
except Exception as exc:
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
_log_shot_ai_model_event(
call_id=call_id,
event_type=event_type,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
task_set_id=task_set_id,
segment_id=segment_id,
mode=mode,
request_data={**log_request_data, "api_url": url},
response_data=raw,
http_status=response.status_code,
message="拆镜视频分析模型内容解析失败",
error=str(exc),
)
raise
result = fill_none_with_wu(result)
result = ensure_result_schema(result)
result = filter_and_normalize_breakdown(result, mode=mode)
raw_usage = raw.get("usage")
usage_reported = bool(
isinstance(raw_usage, dict)
@@ -777,6 +756,8 @@ async def analyze_video_for_shot_split(
"analysis_mode": mode,
"trace_id": trace_id,
"usage_reported": usage_reported,
"provider_request_id": str(raw.get("id") or remote_request_id or "") or None,
"http_status": int(response.status_code),
}
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
@@ -788,6 +769,33 @@ async def analyze_video_for_shot_split(
"model_name": config.model_name,
})
try:
content = get_message_content_or_raise(raw)
result = parse_model_json(content)
result = fill_none_with_wu(result)
result = ensure_result_schema(result)
result = filter_and_normalize_breakdown(result, mode=mode)
except Exception as exc:
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
_log_shot_ai_model_event(
call_id=call_id,
event_type=event_type,
event_status=LogEventStatusEnum.FAILED.value,
config=config,
trace_id=trace_id,
user_id=user_id,
task_set_id=task_set_id,
segment_id=segment_id,
mode=mode,
request_data={**log_request_data, "api_url": url},
response_data=raw,
token_usage=token_usage,
http_status=response.status_code,
message="拆镜视频分析模型内容后处理失败",
error=str(exc),
)
raise LlmProviderPostprocessError(f"拆镜视频分析响应后处理失败: {exc}", usage=token_usage) from exc
_log_shot_ai_model_event(
call_id=call_id,
event_type=(

Some files were not shown because too many files have changed in this diff Show More